diff --git a/.talismanrc b/.talismanrc index dc6154365..fd0b0d239 100644 --- a/.talismanrc +++ b/.talismanrc @@ -10,7 +10,7 @@ fileignoreconfig: - filename: packages/contentstack-query-export/.env-example checksum: 922c7aa9c788ab60b987de2b0a2aee6d90843c463a8bbc29201e4efe31081187 - filename: pnpm-lock.yaml - checksum: bb5303f2fe64f90ae95d2738363267fb0bfcfeb71f025c2110d4cec87ff84d95 + checksum: 3d2eaabf1df366efee1759156465c6aefa68f30d372717de2cdc3e41946aa3d8 - filename: packages/contentstack-import/src/utils/build-import-spaces-options.ts checksum: fe0cb6cb5903515982af1e3642f2a19233207d35f13dc205cebeda0aa399f8b5 - filename: packages/contentstack-export/src/export/modules/stack.ts diff --git a/packages/contentstack-asset-management/README.md b/packages/contentstack-asset-management/README.md deleted file mode 100644 index 87867c247..000000000 --- a/packages/contentstack-asset-management/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# @contentstack/cli-asset-management - -Asset Management 2.0 API adapter for Contentstack CLI export and import. Used by the export and import plugins when Asset Management (AM 2.0) is enabled. To learn how to export and import content in Contentstack, refer to the [Migration guide](https://www.contentstack.com/docs/developers/cli/migration/). - -[![License](https://img.shields.io/npm/l/@contentstack/cli)](https://github.com/contentstack/cli/blob/main/LICENSE) - - -* [@contentstack/cli-asset-management](#contentstackcli-asset-management) -* [Overview](#overview) -* [Usage](#usage) -* [Exports](#exports) - - -# Overview - -This package provides: - -- **AssetManagementAdapter** – HTTP client for the Asset Management API (spaces, assets, folders, fields, asset types). -- **exportSpaceStructure** – Exports space metadata and full workspace structure (metadata, folders, assets, fields, asset types) for linked workspaces. -- **Types** – `AssetManagementExportOptions`, `LinkedWorkspace`, `IAssetManagementAdapter`, and related types for export/import integration. - -# Usage - -This package is consumed by the export and import plugins. When using the export CLI with the `--asset-management` flag (or when the host app enables AM 2.0), the export plugin calls `exportSpaceStructure` with linked workspaces and options: - -```ts -import { exportSpaceStructure } from '@contentstack/cli-asset-management'; - -await exportSpaceStructure({ - linkedWorkspaces, - exportDir, - branchName: 'main', - assetManagementUrl, - org_uid, - context, - progressManager, - progressProcessName, - updateStatus, - downloadAsset, // optional -}); -``` - -# Exports - -| Export | Description | -|--------|-------------| -| `exportSpaceStructure` | Async function to export space structure for given linked workspaces. | -| `AssetManagementAdapter` | Class to call the Asset Management API (getSpace, getWorkspaceFields, getWorkspaceAssets, etc.). | -| Types from `./types` | `AssetManagementExportOptions`, `ExportSpaceOptions`, `ChunkedJsonWriteOptions`, `LinkedWorkspace`, `SpaceResponse`, `FieldsResponse`, `AssetTypesResponse`, and related API types. | diff --git a/packages/contentstack-asset-management/package.json b/packages/contentstack-asset-management/package.json index daa8c5a66..8f7e0978b 100644 --- a/packages/contentstack-asset-management/package.json +++ b/packages/contentstack-asset-management/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-asset-management", "version": "1.0.0-beta.0", - "description": "Asset Management 2.0 API adapter for export and import", + "description": "Contentstack Assets API adapter for export and import", "main": "lib/index.js", "types": "lib/index.d.ts", "files": [ @@ -24,7 +24,8 @@ }, "keywords": [ "contentstack", - "asset-management", + "cs-assets", + "contentstack-assets", "cli" ], "license": "MIT", @@ -37,7 +38,7 @@ "devPlugins": [ "@oclif/plugin-help" ], - "repositoryPrefix": "<%- repo %>/blob/main/packages/contentstack-asset-management/<%- commandPath %>" + "repositoryPrefix": "<%- repo %>/blob/main/packages/contentstack-cs-assets/<%- commandPath %>" }, "devDependencies": { "@types/chai": "^4.3.11", @@ -55,4 +56,4 @@ "ts-node": "^10.9.2", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/packages/contentstack-asset-management/src/constants/index.ts b/packages/contentstack-asset-management/src/constants/index.ts index 9d6bca636..cd0aa4ef5 100644 --- a/packages/contentstack-asset-management/src/constants/index.ts +++ b/packages/contentstack-asset-management/src/constants/index.ts @@ -29,13 +29,25 @@ export const FALLBACK_ASSET_TYPES_IMPORT_INVALID_KEYS = [ export const CHUNK_FILE_SIZE_MB = FALLBACK_AM_CHUNK_FILE_SIZE_MB; /** - * Main process name for Asset Management 2.0 export (single progress bar). + * Main process name for Contentstack Assets export (single progress bar). * Use this when adding/starting the process and for all ticks. */ -export const AM_MAIN_PROCESS_NAME = 'Asset Management 2.0'; +export const CS_ASSETS_MAIN_PROCESS_NAME = 'Contentstack Assets'; +/** @deprecated Use CS_ASSETS_MAIN_PROCESS_NAME */ +export const AM_MAIN_PROCESS_NAME = CS_ASSETS_MAIN_PROCESS_NAME; /** - * Process names for Asset Management 2.0 export progress (for tick labels). + * Process names for Contentstack Assets export/import progress. + * + * In the new per-space layout each entry below corresponds to a single row in + * the multibar: + * - {@link AM_FIELDS} / {@link AM_ASSET_TYPES} are the shared bootstrap rows + * (one execution per org, ahead of per-space work). + * - {@link AM_IMPORT_FIELDS} / {@link AM_IMPORT_ASSET_TYPES} are the import + * equivalents. + * - One additional row per space is added dynamically via + * {@link getSpaceProcessName} and ticks include folders + metadata + asset + * transfer for that space. */ export const PROCESS_NAMES = { AM_SPACE_METADATA: 'Space metadata', @@ -51,6 +63,38 @@ export const PROCESS_NAMES = { AM_IMPORT_ASSETS: 'Import assets', } as const; +/** + * Maximum visual length of a per-space process row label. The CLIProgressManager + * truncates anything over 20 characters; reserve 6 chars for the `Space ` prefix + * so the trailing space uid keeps 14 chars before truncation. + */ +const SPACE_PROCESS_NAME_PREFIX = 'Space '; +const SPACE_PROCESS_NAME_MAX_UID_LEN = 14; + +/** + * Returns the multibar row label for a single CS Assets space. + * The label is bounded so CLIProgressManager.formatProcessName doesn't truncate + * it mid-string; the full uid is still used for tick item labels and structured + * logs, only the row label itself is shortened for display. + */ +export function getSpaceProcessName(spaceUid: string): string { + const safeUid = spaceUid ?? ''; + const trimmed = + safeUid.length > SPACE_PROCESS_NAME_MAX_UID_LEN + ? safeUid.substring(0, SPACE_PROCESS_NAME_MAX_UID_LEN) + : safeUid; + return `${SPACE_PROCESS_NAME_PREFIX}${trimmed}`; +} + +/** + * Detects whether a process name belongs to a per-space progress row, used by + * the export/import strategy registries to aggregate counts for the final + * summary across all spaces. + */ +export function isSpaceProcessName(processName: string): boolean { + return typeof processName === 'string' && processName.startsWith(SPACE_PROCESS_NAME_PREFIX); +} + /** * Status messages for each process (exporting, fetching, importing, failed). */ diff --git a/packages/contentstack-asset-management/src/export/asset-types.ts b/packages/contentstack-asset-management/src/export/asset-types.ts index 6223b38d5..50487195e 100644 --- a/packages/contentstack-asset-management/src/export/asset-types.ts +++ b/packages/contentstack-asset-management/src/export/asset-types.ts @@ -1,13 +1,15 @@ import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementExportAdapter } from './base'; +import { CSAssetsExportAdapter } from './base'; import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; -export default class ExportAssetTypes extends AssetManagementExportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { +export default class ExportAssetTypes extends CSAssetsExportAdapter { + protected processName: string = PROCESS_NAMES.AM_ASSET_TYPES; + + constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } @@ -24,7 +26,13 @@ export default class ExportAssetTypes extends AssetManagementExportAdapter { } else { log.debug(`Writing ${items.length} shared asset types`, this.exportContext.context); } - await this.writeItemsToChunkedJson(dir, 'asset-types.json', 'asset_types', ['uid', 'title', 'category', 'file_extension'], items); - this.tick(true, PROCESS_NAMES.AM_ASSET_TYPES, null); + await this.writeItemsToChunkedJson( + dir, + 'asset-types.json', + 'asset_types', + ['uid', 'title', 'category', 'file_extension'], + items, + ); + this.tick(true, `asset_types (${items.length})`, null); } } diff --git a/packages/contentstack-asset-management/src/export/assets.ts b/packages/contentstack-asset-management/src/export/assets.ts index acd0f1676..6cc1129a3 100644 --- a/packages/contentstack-asset-management/src/export/assets.ts +++ b/packages/contentstack-asset-management/src/export/assets.ts @@ -3,15 +3,15 @@ import { Readable } from 'node:stream'; import { mkdir, writeFile } from 'node:fs/promises'; import { configHandler, log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-management-api'; +import type { CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementExportAdapter } from './base'; +import { CSAssetsExportAdapter } from './base'; import { getAssetItems, writeStreamToFile } from '../utils/export-helpers'; import { runInBatches } from '../utils/concurrent-batch'; import { PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; -export default class ExportAssets extends AssetManagementExportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { +export default class ExportAssets extends CSAssetsExportAdapter { + constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } @@ -32,11 +32,17 @@ export default class ExportAssets extends AssetManagementExportAdapter { this.getWorkspaceAssets(workspace.space_uid, workspace.uid), ]); + const assetItems = getAssetItems(assetsData); + const downloadableCount = assetItems.filter((asset) => Boolean(asset.url && (asset.uid ?? asset._uid))).length; + // Per-space total: 1 folder write + 1 metadata write + N per-asset downloads. + // The shared module-level total is just a placeholder before this point; update + // it now so the multibar row shows real progress as downloads tick in. + this.progressOrParent?.updateProcessTotal?.(this.processName, 2 + downloadableCount); + await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2)); this.tick(true, `folders: ${workspace.space_uid}`, null); log.debug(`Wrote folders.json for space ${workspace.space_uid}`, this.exportContext.context); - const assetItems = getAssetItems(assetsData); log.debug( assetItems.length === 0 ? `No assets for space ${workspace.space_uid}, wrote empty assets.json` @@ -60,7 +66,7 @@ export default class ExportAssets extends AssetManagementExportAdapter { : `Wrote ${assetItems.length} asset metadata record(s) for space ${workspace.space_uid}`, this.exportContext.context, ); - this.tick(true, `assets: ${workspace.space_uid} (${assetItems.length})`, null); + this.tick(true, `metadata: ${workspace.space_uid} (${assetItems.length})`, null); log.debug(`Starting binary downloads for space ${workspace.space_uid}`, this.exportContext.context); await this.downloadWorkspaceAssets(assetsData, assetsDir, workspace.space_uid); @@ -87,8 +93,6 @@ export default class ExportAssets extends AssetManagementExportAdapter { `Asset downloads: securedAssets=${securedAssets}, concurrency=${this.downloadAssetsBatchConcurrency}`, this.exportContext.context, ); - let lastError: string | null = null; - let allSuccess = true; let downloadOk = 0; let downloadFail = 0; @@ -118,24 +122,25 @@ export default class ExportAssets extends AssetManagementExportAdapter { const filePath = pResolve(assetFolderPath, filename); await writeStreamToFile(nodeStream, filePath); downloadOk += 1; + // Per-asset tick so the per-space progress bar moves in real time. + this.tick(true, `asset: ${filename}`, null); log.debug(`Downloaded asset ${uid} → ${filePath}`, this.exportContext.context); } catch (e) { - allSuccess = false; downloadFail += 1; - lastError = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED; + const err = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED; + this.tick(false, `asset: ${filename}`, err); log.debug(`Failed to download asset ${uid}: ${e}`, this.exportContext.context); } }); - this.tick(allSuccess, `downloads: ${spaceUid}`, lastError); log.info( - allSuccess + downloadFail === 0 ? `Finished downloading ${downloadOk} asset file(s) for space ${spaceUid}` : `Asset downloads for space ${spaceUid} completed with errors: ${downloadOk} succeeded, ${downloadFail} failed`, this.exportContext.context, ); log.debug( - `Asset downloads finished for space ${spaceUid}: ok=${downloadOk}, failed=${downloadFail}, allSuccess=${allSuccess}`, + `Asset downloads finished for space ${spaceUid}: ok=${downloadOk}, failed=${downloadFail}`, this.exportContext.context, ); } diff --git a/packages/contentstack-asset-management/src/export/base.ts b/packages/contentstack-asset-management/src/export/base.ts index 055d2d3ba..b9721685c 100644 --- a/packages/contentstack-asset-management/src/export/base.ts +++ b/packages/contentstack-asset-management/src/export/base.ts @@ -2,10 +2,10 @@ import { resolve as pResolve } from 'node:path'; import { writeFile } from 'node:fs/promises'; import { FsUtility, log, CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; -import { AM_MAIN_PROCESS_NAME, FALLBACK_AM_API_CONCURRENCY, FALLBACK_AM_CHUNK_FILE_SIZE_MB } from '../constants/index'; +import { CSAssetsAdapter } from '../utils/cs-assets-api-adapter'; +import { CS_ASSETS_MAIN_PROCESS_NAME, FALLBACK_AM_API_CONCURRENCY, FALLBACK_AM_CHUNK_FILE_SIZE_MB } from '../constants/index'; export type { ExportContext }; @@ -13,14 +13,14 @@ export type { ExportContext }; * Base class for export modules. Extends the API adapter and adds export context, * internal progress management, and shared write helpers. */ -export class AssetManagementExportAdapter extends AssetManagementAdapter { - protected readonly apiConfig: AssetManagementAPIConfig; +export class CSAssetsExportAdapter extends CSAssetsAdapter { + protected readonly apiConfig: CSAssetsAPIConfig; protected readonly exportContext: ExportContext; protected progressManager: CLIProgressManager | null = null; protected parentProgressManager: CLIProgressManager | null = null; - protected readonly processName: string = AM_MAIN_PROCESS_NAME; + protected processName: string = CS_ASSETS_MAIN_PROCESS_NAME; - constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) { super(apiConfig); this.apiConfig = apiConfig; this.exportContext = exportContext; @@ -30,6 +30,15 @@ export class AssetManagementExportAdapter extends AssetManagementAdapter { this.parentProgressManager = parent; } + /** + * Override the default progress process name for {@link tick}/{@link updateStatus} + * calls. Used by the per-space orchestrator so each module's ticks land on the + * row for the space currently being exported. + */ + public setProcessName(name: string): void { + this.processName = name; + } + protected get progressOrParent(): CLIProgressManager | null { return this.parentProgressManager ?? this.progressManager; } diff --git a/packages/contentstack-asset-management/src/export/fields.ts b/packages/contentstack-asset-management/src/export/fields.ts index fd997e5e5..c1ca623f8 100644 --- a/packages/contentstack-asset-management/src/export/fields.ts +++ b/packages/contentstack-asset-management/src/export/fields.ts @@ -1,13 +1,15 @@ import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementExportAdapter } from './base'; +import { CSAssetsExportAdapter } from './base'; import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; -export default class ExportFields extends AssetManagementExportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { +export default class ExportFields extends CSAssetsExportAdapter { + protected processName: string = PROCESS_NAMES.AM_FIELDS; + + constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } @@ -25,6 +27,6 @@ export default class ExportFields extends AssetManagementExportAdapter { log.debug(`Writing ${items.length} shared fields`, this.exportContext.context); } await this.writeItemsToChunkedJson(dir, 'fields.json', 'fields', ['uid', 'title', 'display_type'], items); - this.tick(true, PROCESS_NAMES.AM_FIELDS, null); + this.tick(true, `fields (${items.length})`, null); } } diff --git a/packages/contentstack-asset-management/src/export/index.ts b/packages/contentstack-asset-management/src/export/index.ts index 7d71e361e..14727ed70 100644 --- a/packages/contentstack-asset-management/src/export/index.ts +++ b/packages/contentstack-asset-management/src/export/index.ts @@ -3,5 +3,5 @@ export { default as ExportAssetTypes } from './asset-types'; export { default as ExportFields } from './fields'; export { default as ExportAssets } from './assets'; export { default as ExportWorkspace } from './workspaces'; -export { AssetManagementExportAdapter } from './base'; +export { CSAssetsExportAdapter } from './base'; export type { ExportContext } from './base'; diff --git a/packages/contentstack-asset-management/src/export/spaces.ts b/packages/contentstack-asset-management/src/export/spaces.ts index 24a5a088d..3a3459c3f 100644 --- a/packages/contentstack-asset-management/src/export/spaces.ts +++ b/packages/contentstack-asset-management/src/export/spaces.ts @@ -2,16 +2,15 @@ import { resolve as pResolve } from 'node:path'; import { mkdir } from 'node:fs/promises'; import { log, CLIProgressManager, configHandler, handleAndLogError } from '@contentstack/cli-utilities'; -import type { AssetManagementExportOptions, AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { AssetManagementExportOptions, CSAssetsAPIConfig } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; -import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; +import { CS_ASSETS_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; import ExportAssetTypes from './asset-types'; import ExportFields from './fields'; import ExportWorkspace from './workspaces'; /** - * Orchestrates the full Asset Management 2.0 export: shared asset types and fields, + * Orchestrates the full Contentstack Assets export: shared asset types and fields, * then per-workspace metadata and assets (including internal download). * Progress and download are fully owned by this package. */ @@ -33,7 +32,7 @@ export class ExportSpaces { linkedWorkspaces, exportDir, branchName, - assetManagementUrl, + csAssetsUrl, org_uid, apiKey, context, @@ -46,24 +45,30 @@ export class ExportSpaces { return; } - log.debug('Starting Asset Management export process...', context); - log.info('Started Asset Management export', context); - log.debug(`Exporting Asset Management 2.0 (${linkedWorkspaces.length} space(s))`, context); + log.debug('Starting Contentstack Assets export process...', context); + log.info('Started Contentstack Assets export', context); + log.debug(`Exporting Contentstack Assets (${linkedWorkspaces.length} space(s))`, context); log.debug(`Spaces: ${linkedWorkspaces.map((ws) => ws.space_uid).join(', ')}`, context); const spacesRootPath = pResolve(exportDir, 'spaces'); await mkdir(spacesRootPath, { recursive: true }); log.debug(`Spaces root path: ${spacesRootPath}`, context); - const totalSteps = 2 + linkedWorkspaces.length * 4; const progress = this.createProgress(); - progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps); - progress - .startProcess(AM_MAIN_PROCESS_NAME) - .updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_FIELDS].FETCHING, AM_MAIN_PROCESS_NAME); + // Multibar layout: two shared bootstrap rows + one row per space. Per-space + // totals start at 1 and are bumped to (2 + downloadableCount) inside + // ExportAssets.start once we know the asset count for that space. + progress.addProcess(PROCESS_NAMES.AM_FIELDS, 1); + progress.addProcess(PROCESS_NAMES.AM_ASSET_TYPES, 1); + const spaceProcessNames = new Map(); + for (const ws of linkedWorkspaces) { + const spaceProcess = getSpaceProcessName(ws.space_uid); + spaceProcessNames.set(ws.space_uid, spaceProcess); + progress.addProcess(spaceProcess, 1); + } - const apiConfig: AssetManagementAPIConfig = { - baseURL: assetManagementUrl, + const apiConfig: CSAssetsAPIConfig = { + baseURL: csAssetsUrl, headers: { organization_uid: org_uid }, context, }; @@ -82,40 +87,68 @@ export class ExportSpaces { await mkdir(sharedAssetTypesDir, { recursive: true }); const firstSpaceUid = linkedWorkspaces[0].space_uid; + let bootstrapFailed = false; + let anySpaceFailed = false; try { + progress.startProcess(PROCESS_NAMES.AM_FIELDS); + progress.startProcess(PROCESS_NAMES.AM_ASSET_TYPES); + const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext); exportAssetTypes.setParentProgressManager(progress); const exportFields = new ExportFields(apiConfig, exportContext); exportFields.setParentProgressManager(progress); - await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]); + try { + await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]); + progress.completeProcess(PROCESS_NAMES.AM_FIELDS, true); + progress.completeProcess(PROCESS_NAMES.AM_ASSET_TYPES, true); + } catch (bootstrapErr) { + bootstrapFailed = true; + progress.completeProcess(PROCESS_NAMES.AM_FIELDS, false); + progress.completeProcess(PROCESS_NAMES.AM_ASSET_TYPES, false); + throw bootstrapErr; + } for (const ws of linkedWorkspaces) { - progress.updateStatus(`Exporting space: ${ws.space_uid}...`, AM_MAIN_PROCESS_NAME); + const spaceProcess = spaceProcessNames.get(ws.space_uid)!; + progress.startProcess(spaceProcess); log.debug(`Exporting space: ${ws.space_uid}`, context); const spaceDir = pResolve(spacesRootPath, ws.space_uid); try { const exportWorkspace = new ExportWorkspace(apiConfig, exportContext); exportWorkspace.setParentProgressManager(progress); - await exportWorkspace.start(ws, spaceDir, branchName || 'main'); + await exportWorkspace.start(ws, spaceDir, branchName || 'main', spaceProcess); + progress.completeProcess(spaceProcess, true); log.debug(`Exported workspace structure for space ${ws.space_uid}`, context); } catch (err) { + // Per-space failure: mark the row failed and continue with the next + // space so partial export results are preserved (matches import). + anySpaceFailed = true; log.debug(`Failed to export workspace for space ${ws.space_uid}: ${err}`, context); - progress.tick( - false, - `space: ${ws.space_uid}`, - (err as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_SPACE_METADATA].FAILED, - AM_MAIN_PROCESS_NAME, + handleAndLogError( + err, + { ...(context as Record), spaceUid: ws.space_uid }, + `Failed to export space ${ws.space_uid}`, ); - throw err; + progress.completeProcess(spaceProcess, false); } } - progress.completeProcess(AM_MAIN_PROCESS_NAME, true); - log.info('Asset Management export completed successfully', context); - log.debug('Asset Management 2.0 export completed', context); + log.info( + anySpaceFailed + ? 'Contentstack Assets export completed with errors in one or more spaces' + : 'Contentstack Assets export completed successfully', + context, + ); + log.debug('Contentstack Assets export completed', context); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); - handleAndLogError(err, { ...(context as Record) }, 'Asset Management export failed'); + if (!bootstrapFailed) { + // Mark any spaces that hadn't been processed as failed so the multibar + // doesn't leave dangling pending rows. + for (const [, spaceProcess] of spaceProcessNames) { + progress.completeProcess(spaceProcess, false); + } + } + handleAndLogError(err, { ...(context as Record) }, 'Contentstack Assets export failed'); throw err; } } @@ -127,7 +160,7 @@ export class ExportSpaces { } const logConfig = configHandler.get('log') || {}; const showConsoleLogs = logConfig.showConsoleLogs ?? false; - this.progressManager = CLIProgressManager.createNested(AM_MAIN_PROCESS_NAME, showConsoleLogs); + this.progressManager = CLIProgressManager.createNested(CS_ASSETS_MAIN_PROCESS_NAME, showConsoleLogs); return this.progressManager; } } diff --git a/packages/contentstack-asset-management/src/export/workspaces.ts b/packages/contentstack-asset-management/src/export/workspaces.ts index c2f5bb4f1..14dd5c1a5 100644 --- a/packages/contentstack-asset-management/src/export/workspaces.ts +++ b/packages/contentstack-asset-management/src/export/workspaces.ts @@ -2,20 +2,37 @@ import { resolve as pResolve } from 'node:path'; import { mkdir, writeFile } from 'node:fs/promises'; import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-management-api'; +import type { CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementExportAdapter } from './base'; +import { CSAssetsExportAdapter } from './base'; import ExportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; -export default class ExportWorkspace extends AssetManagementExportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { +export default class ExportWorkspace extends CSAssetsExportAdapter { + constructor(apiConfig: CSAssetsAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } - async start(workspace: LinkedWorkspace, spaceDir: string, branchName: string): Promise { + /** + * Run the export pipeline for a single space. + * + * The optional `spaceProcessName` is the multibar row label that ticks + * (folder write + metadata write + per-asset downloads) should land on. The + * orchestrator passes the per-space row produced by `getSpaceProcessName`; + * if omitted the default {@link processName} (the AM main row) is used so + * direct callers keep working. + */ + async start( + workspace: LinkedWorkspace, + spaceDir: string, + branchName: string, + spaceProcessName?: string, + ): Promise { await this.init(); + if (spaceProcessName) { + this.setProcessName(spaceProcessName); + } + log.debug(`Starting export for AM space ${workspace.space_uid}`, this.exportContext.context); const spaceResponse = await this.getSpace(workspace.space_uid); @@ -35,11 +52,13 @@ export default class ExportWorkspace extends AssetManagementExportAdapter { log.warn(`Could not write ${metadataPath}: ${e}`, this.exportContext.context); throw e; } - this.tick(true, `space: ${workspace.space_uid}`, null); log.debug(`Space metadata written for ${workspace.space_uid}`, this.exportContext.context); const assetsExporter = new ExportAssets(this.apiConfig, this.exportContext); if (this.progressOrParent) assetsExporter.setParentProgressManager(this.progressOrParent); + if (spaceProcessName) { + assetsExporter.setProcessName(spaceProcessName); + } await assetsExporter.start(workspace, spaceDir); log.debug(`Exported workspace structure for space ${workspace.space_uid}`, this.exportContext.context); } diff --git a/packages/contentstack-asset-management/src/import/asset-types.ts b/packages/contentstack-asset-management/src/import/asset-types.ts index 71f5fbbac..dfc997512 100644 --- a/packages/contentstack-asset-management/src/import/asset-types.ts +++ b/packages/contentstack-asset-management/src/import/asset-types.ts @@ -4,8 +4,8 @@ import omit from 'lodash/omit'; import isEqual from 'lodash/isEqual'; import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, ImportContext } from '../types/asset-management-api'; -import { AssetManagementImportAdapter } from './base'; +import type { CSAssetsAPIConfig, ImportContext } from '../types/cs-assets-api'; +import { CSAssetsImportAdapter } from './base'; import { FALLBACK_ASSET_TYPES_IMPORT_INVALID_KEYS, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; import { runInBatches } from '../utils/concurrent-batch'; import { forEachChunkedJsonStore } from '../utils/chunked-json-reader'; @@ -23,8 +23,13 @@ type AssetTypeToCreate = { uid: string; payload: Record }; * 4. If uid already exists and definition matches → silently skip. * 5. Strip read-only/computed keys from the POST body before creating new asset types. */ -export default class ImportAssetTypes extends AssetManagementImportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { +export default class ImportAssetTypes extends CSAssetsImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_ASSET_TYPES; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + + constructor(apiConfig: CSAssetsAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } @@ -40,15 +45,13 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { if (!existsSync(indexPath)) { log.info('No shared asset types to import (index missing)', this.importContext.context); + this.tick(true, 'asset_types (0)', null); return; } const existingByUid = await this.loadExistingAssetTypesMap(); - this.updateStatus( - PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].IMPORTING, - PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, - ); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].IMPORTING); await forEachChunkedJsonStore>( dir, @@ -64,6 +67,12 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { await this.importAssetTypesCreates(toCreate); }, ); + + this.tick( + this.failureCount === 0, + `asset_types: ${this.successCount} created, ${this.skippedCount} skipped, ${this.failureCount} failed`, + this.failureCount > 0 ? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].FAILED : null, + ); } /** Org-level asset types keyed by uid for diff; empty map if list API fails. */ @@ -111,7 +120,7 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { this.importContext.context, ); } - this.tick(true, `asset-type: ${uid} (skipped, already exists)`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); + this.skippedCount += 1; continue; } @@ -125,15 +134,10 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { await runInBatches(toCreate, this.apiConcurrency, async ({ uid, payload }) => { try { await this.createAssetType(payload as any); - this.tick(true, `asset-type: ${uid}`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); + this.successCount += 1; log.debug(`Imported asset type: ${uid}`, this.importContext.context); } catch (e) { - this.tick( - false, - `asset-type: ${uid}`, - (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].FAILED, - PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, - ); + this.failureCount += 1; log.debug(`Failed to import asset type ${uid}: ${e}`, this.importContext.context); } }); diff --git a/packages/contentstack-asset-management/src/import/assets.ts b/packages/contentstack-asset-management/src/import/assets.ts index b69721245..5b1187410 100644 --- a/packages/contentstack-asset-management/src/import/assets.ts +++ b/packages/contentstack-asset-management/src/import/assets.ts @@ -2,8 +2,8 @@ import { resolve as pResolve, join } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { FsUtility, log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, ImportContext } from '../types/asset-management-api'; -import { AssetManagementImportAdapter } from './base'; +import type { CSAssetsAPIConfig, ImportContext } from '../types/cs-assets-api'; +import { CSAssetsImportAdapter } from './base'; import { getArrayFromResponse } from '../utils/export-helpers'; import { runInBatches } from '../utils/concurrent-batch'; import { forEachChunkRecordsFromFs } from '../utils/chunked-json-reader'; @@ -40,8 +40,8 @@ type UploadJob = { * - Builds UID and URL mapper entries for entries.ts consumption * Mirrors ExportAssets. */ -export default class ImportAssets extends AssetManagementImportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { +export default class ImportAssets extends CSAssetsImportAdapter { + constructor(apiConfig: CSAssetsAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } @@ -127,58 +127,62 @@ export default class ImportAssets extends AssetManagementImportAdapter { log.debug(`Assets directory: ${assetsDir}`, this.importContext.context); // ----------------------------------------------------------------------- - // 1. Import folders + // 0. Pre-count folders and assets so the per-space progress row knows the + // real total upfront. Each folder/asset is a single tick below. // ----------------------------------------------------------------------- - const folderUidMap: Record = {}; const foldersFileName = this.importContext.foldersFileName ?? 'folders.json'; const foldersFilePath = join(assetsDir, foldersFileName); + const folders = this.readFolders(foldersFilePath, foldersFileName); + const folderCount = folders.length; - if (!existsSync(foldersFilePath)) { - log.debug(`No ${foldersFileName} at ${foldersFilePath}, skipping folder import`, this.importContext.context); - } + const loc = this.resolveAssetsChunkedLocation(spaceDir); + const assetCount = loc ? this.countAssetsInChunkedStore(loc.assetsDir, loc.indexName) : 0; - if (existsSync(foldersFilePath)) { - let foldersData: unknown; - try { - foldersData = JSON.parse(readFileSync(foldersFilePath, 'utf8')); - } catch (e) { - log.warn(`Could not read ${foldersFileName}: ${e}`, this.importContext.context); - } + // Update the per-space row to fold + assets (min 1 so the bar shows + // something even for empty spaces). + this.progressOrParent?.updateProcessTotal?.(this.processName, Math.max(1, folderCount + assetCount)); - if (foldersData) { - log.debug(`Reading folders from ${foldersFilePath}`, this.importContext.context); - const folders = getArrayFromResponse(foldersData, 'folders') as FolderRecord[]; - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].IMPORTING, PROCESS_NAMES.AM_IMPORT_FOLDERS); - log.debug( - `Importing ${folders.length} folder(s) for space ${newSpaceUid} (concurrency=${this.importFoldersBatchConcurrency})`, - this.importContext.context, - ); - await this.importFolders(newSpaceUid, folders, folderUidMap); - log.debug( - `Folder import phase complete: ${Object.keys(folderUidMap).length} exported folder uid(s) mapped to target`, - this.importContext.context, - ); - log.info( - `Finished importing ${Object.keys(folderUidMap).length} folder(s) for space ${newSpaceUid}`, - this.importContext.context, - ); - } + // ----------------------------------------------------------------------- + // 1. Import folders + // ----------------------------------------------------------------------- + const folderUidMap: Record = {}; + + if (folderCount > 0) { + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].IMPORTING); + log.debug( + `Importing ${folderCount} folder(s) for space ${newSpaceUid} (concurrency=${this.importFoldersBatchConcurrency})`, + this.importContext.context, + ); + await this.importFolders(newSpaceUid, folders, folderUidMap); + log.debug( + `Folder import phase complete: ${Object.keys(folderUidMap).length} exported folder uid(s) mapped to target`, + this.importContext.context, + ); + log.info( + `Finished importing ${Object.keys(folderUidMap).length} folder(s) for space ${newSpaceUid}`, + this.importContext.context, + ); + } else { + log.debug(`No ${foldersFileName} at ${foldersFilePath}, skipping folder import`, this.importContext.context); } // ----------------------------------------------------------------------- // 2. Import assets (chunked on disk — process one chunk file at a time) // ----------------------------------------------------------------------- - const loc = this.resolveAssetsChunkedLocation(spaceDir); if (!loc) { log.info( `No asset metadata index in ${assetsDir}; skipping file uploads for space ${newSpaceUid}`, this.importContext.context, ); log.debug(`No assets.json index found in ${assetsDir}, skipping asset upload`, this.importContext.context); + // Empty space — bump current to total (1) so the row reads 100%. + if (folderCount === 0) { + this.tick(true, `space: ${newSpaceUid} (empty)`, null); + } return { uidMap, urlMap }; } - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].IMPORTING, PROCESS_NAMES.AM_IMPORT_ASSETS); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].IMPORTING); log.debug( `Uploading assets for space ${newSpaceUid} from ${loc.assetsDir} (index: ${loc.indexName}, concurrency=${this.uploadAssetsBatchConcurrency})`, this.importContext.context, @@ -205,7 +209,7 @@ export default class ImportAssets extends AssetManagementImportAdapter { if (!existsSync(filePath)) { missingFiles += 1; log.warn(`Asset file not found: ${filePath}, skipping`, this.importContext.context); - this.tick(false, `asset: ${oldUid}`, 'File not found on disk', PROCESS_NAMES.AM_IMPORT_ASSETS); + this.tick(false, `asset: ${oldUid}`, 'File not found on disk'); continue; } @@ -239,16 +243,15 @@ export default class ImportAssets extends AssetManagementImportAdapter { urlMap[asset.url] = created.url; } - this.tick(true, `asset: ${oldUid}`, null, PROCESS_NAMES.AM_IMPORT_ASSETS); + this.tick(true, `asset: ${filename}`, null); uploadOk += 1; log.debug(`Uploaded asset ${oldUid} → ${created.uid} (${filePath})`, this.importContext.context); } catch (e) { uploadFail += 1; this.tick( false, - `asset: ${oldUid}`, + `asset: ${filename}`, (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].FAILED, - PROCESS_NAMES.AM_IMPORT_ASSETS, ); log.debug(`Failed to upload asset ${oldUid}: ${e}`, this.importContext.context); } @@ -271,6 +274,45 @@ export default class ImportAssets extends AssetManagementImportAdapter { return { uidMap, urlMap }; } + /** + * Read folders.json into a list, returning [] when the file is absent or + * unreadable. Side-effects (warnings) match the legacy in-line behaviour so + * callers can rely on the return as a count source. + */ + private readFolders(foldersFilePath: string, foldersFileName: string): FolderRecord[] { + if (!existsSync(foldersFilePath)) { + return []; + } + try { + const data = JSON.parse(readFileSync(foldersFilePath, 'utf8')); + log.debug(`Reading folders from ${foldersFilePath}`, this.importContext.context); + return getArrayFromResponse(data, 'folders') as FolderRecord[]; + } catch (e) { + log.warn(`Could not read ${foldersFileName}: ${e}`, this.importContext.context); + return []; + } + } + + /** + * Sum the asset count across all chunk metadata files for the per-space row + * total. Reads `metadata.json` once (cheap aggregate); avoids streaming the + * full chunk payloads twice. + */ + private countAssetsInChunkedStore(assetsDir: string, indexName: string): number { + try { + const fs = new FsUtility({ basePath: assetsDir, indexFileName: indexName }); + const meta = fs.getPlainMeta(); + let total = 0; + for (const value of Object.values(meta)) { + if (Array.isArray(value)) total += value.length; + } + return total; + } catch (e) { + log.debug(`Could not pre-count assets in ${assetsDir}: ${e}`, this.importContext.context); + return 0; + } + } + /** * Creates folders respecting hierarchy: parents before children. * Uses multiple passes to handle arbitrary depth without requiring sorted input. @@ -317,14 +359,13 @@ export default class ImportAssets extends AssetManagementImportAdapter { parent_uid: isRootParent ? undefined : folderUidMap[parentUid!], }); folderUidMap[folder.uid] = created.uid; - this.tick(true, `folder: ${folder.uid}`, null, PROCESS_NAMES.AM_IMPORT_FOLDERS); + this.tick(true, `folder: ${folder.title}`, null); log.debug(`Created folder ${folder.uid} → ${created.uid}`, this.importContext.context); } catch (e) { this.tick( false, - `folder: ${folder.uid}`, + `folder: ${folder.title}`, (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].FAILED, - PROCESS_NAMES.AM_IMPORT_FOLDERS, ); log.debug(`Failed to create folder ${folder.uid}: ${e}`, this.importContext.context); } diff --git a/packages/contentstack-asset-management/src/import/base.ts b/packages/contentstack-asset-management/src/import/base.ts index ef1d4c0f5..7bb8e9914 100644 --- a/packages/contentstack-asset-management/src/import/base.ts +++ b/packages/contentstack-asset-management/src/import/base.ts @@ -1,24 +1,24 @@ import { resolve as pResolve } from 'node:path'; import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, ImportContext } from '../types/asset-management-api'; -import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; -import { AM_MAIN_PROCESS_NAME, FALLBACK_AM_API_CONCURRENCY } from '../constants/index'; +import type { CSAssetsAPIConfig, ImportContext } from '../types/cs-assets-api'; +import { CSAssetsAdapter } from '../utils/cs-assets-api-adapter'; +import { CS_ASSETS_MAIN_PROCESS_NAME, FALLBACK_AM_API_CONCURRENCY } from '../constants/index'; export type { ImportContext }; /** - * Base class for all AM 2.0 import modules. Mirrors AssetManagementExportAdapter + * Base class for all CS Assets import modules. Mirrors CSAssetsExportAdapter * but carries ImportContext (spacesRootPath, apiKey, host, etc.) instead of ExportContext. */ -export class AssetManagementImportAdapter extends AssetManagementAdapter { - protected readonly apiConfig: AssetManagementAPIConfig; +export class CSAssetsImportAdapter extends CSAssetsAdapter { + protected readonly apiConfig: CSAssetsAPIConfig; protected readonly importContext: ImportContext; protected progressManager: CLIProgressManager | null = null; protected parentProgressManager: CLIProgressManager | null = null; - protected readonly processName: string = AM_MAIN_PROCESS_NAME; + protected processName: string = CS_ASSETS_MAIN_PROCESS_NAME; - constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { + constructor(apiConfig: CSAssetsAPIConfig, importContext: ImportContext) { super(apiConfig); this.apiConfig = apiConfig; this.importContext = importContext; @@ -28,6 +28,15 @@ export class AssetManagementImportAdapter extends AssetManagementAdapter { this.parentProgressManager = parent; } + /** + * Override the default progress process name for {@link tick}/{@link updateStatus} + * calls. Used by the per-space orchestrator so each module's ticks land on the + * row for the space currently being imported. + */ + public setProcessName(name: string): void { + this.processName = name; + } + protected get progressOrParent(): CLIProgressManager | null { return this.parentProgressManager ?? this.progressManager; } @@ -61,7 +70,7 @@ export class AssetManagementImportAdapter extends AssetManagementAdapter { return this.importContext.spacesRootPath; } - /** Parallel AM API limit for import batches. */ + /** Parallel CS Assets API limit for import batches. */ protected get apiConcurrency(): number { return this.importContext.apiConcurrency ?? FALLBACK_AM_API_CONCURRENCY; } diff --git a/packages/contentstack-asset-management/src/import/fields.ts b/packages/contentstack-asset-management/src/import/fields.ts index 9785906c2..cf0747598 100644 --- a/packages/contentstack-asset-management/src/import/fields.ts +++ b/packages/contentstack-asset-management/src/import/fields.ts @@ -4,8 +4,8 @@ import omit from 'lodash/omit'; import isEqual from 'lodash/isEqual'; import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, ImportContext } from '../types/asset-management-api'; -import { AssetManagementImportAdapter } from './base'; +import type { CSAssetsAPIConfig, ImportContext } from '../types/cs-assets-api'; +import { CSAssetsImportAdapter } from './base'; import { FALLBACK_FIELDS_IMPORT_INVALID_KEYS, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; import { runInBatches } from '../utils/concurrent-batch'; import { forEachChunkedJsonStore } from '../utils/chunked-json-reader'; @@ -23,8 +23,13 @@ type FieldToCreate = { uid: string; payload: Record }; * 4. If uid already exists and definition matches → silently skip. * 5. Strip read-only/computed keys from the POST body before creating new fields. */ -export default class ImportFields extends AssetManagementImportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { +export default class ImportFields extends CSAssetsImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_FIELDS; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + + constructor(apiConfig: CSAssetsAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } @@ -40,12 +45,15 @@ export default class ImportFields extends AssetManagementImportAdapter { if (!existsSync(indexPath)) { log.info('No shared fields to import (index missing)', this.importContext.context); + // Single aggregate tick so the shared row in the multibar still completes + // even when there is nothing to import. + this.tick(true, 'fields (0)', null); return; } const existingByUid = await this.loadExistingFieldsMap(); - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].IMPORTING, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].IMPORTING); await forEachChunkedJsonStore>( dir, @@ -61,6 +69,15 @@ export default class ImportFields extends AssetManagementImportAdapter { await this.importFieldsCreates(toCreate); }, ); + + // Aggregate tick at end so the single-row shared bootstrap bar reaches 100% + // regardless of how many chunks/items were processed; the per-field outcome + // is still captured in logs. + this.tick( + this.failureCount === 0, + `fields: ${this.successCount} created, ${this.skippedCount} skipped, ${this.failureCount} failed`, + this.failureCount > 0 ? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].FAILED : null, + ); } /** Org-level fields keyed by uid for diff; empty map if list API fails. */ @@ -105,7 +122,7 @@ export default class ImportFields extends AssetManagementImportAdapter { } else { log.debug(`Field "${uid}" already exists with matching definition, skipping`, this.importContext.context); } - this.tick(true, `field: ${uid} (skipped, already exists)`, null, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.skippedCount += 1; continue; } @@ -119,15 +136,10 @@ export default class ImportFields extends AssetManagementImportAdapter { await runInBatches(toCreate, this.apiConcurrency, async ({ uid, payload }) => { try { await this.createField(payload as any); - this.tick(true, `field: ${uid}`, null, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.successCount += 1; log.debug(`Imported field: ${uid}`, this.importContext.context); } catch (e) { - this.tick( - false, - `field: ${uid}`, - (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].FAILED, - PROCESS_NAMES.AM_IMPORT_FIELDS, - ); + this.failureCount += 1; log.debug(`Failed to import field ${uid}: ${e}`, this.importContext.context); } }); diff --git a/packages/contentstack-asset-management/src/import/index.ts b/packages/contentstack-asset-management/src/import/index.ts index 61d8a457e..0ab77f5c7 100644 --- a/packages/contentstack-asset-management/src/import/index.ts +++ b/packages/contentstack-asset-management/src/import/index.ts @@ -3,5 +3,5 @@ export { default as ImportWorkspace } from './workspaces'; export { default as ImportAssets } from './assets'; export { default as ImportFields } from './fields'; export { default as ImportAssetTypes } from './asset-types'; -export { AssetManagementImportAdapter } from './base'; +export { CSAssetsImportAdapter } from './base'; export type { ImportContext } from './base'; diff --git a/packages/contentstack-asset-management/src/import/spaces.ts b/packages/contentstack-asset-management/src/import/spaces.ts index 6f66d24be..e96e2ff17 100644 --- a/packages/contentstack-asset-management/src/import/spaces.ts +++ b/packages/contentstack-asset-management/src/import/spaces.ts @@ -4,20 +4,20 @@ import { writeFile } from 'node:fs/promises'; import { log, CLIProgressManager, configHandler, handleAndLogError } from '@contentstack/cli-utilities'; import type { - AssetManagementAPIConfig, + CSAssetsAPIConfig, ImportContext, ImportResult, ImportSpacesOptions, SpaceMapping, -} from '../types/asset-management-api'; -import { AM_MAIN_PROCESS_NAME } from '../constants/index'; -import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; +} from '../types/cs-assets-api'; +import { CS_ASSETS_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; +import { CSAssetsAdapter } from '../utils/cs-assets-api-adapter'; import ImportAssetTypes from './asset-types'; import ImportFields from './fields'; import ImportWorkspace from './workspaces'; /** - * Top-level orchestrator for AM 2.0 import. + * Top-level orchestrator for CS Assets import. * Mirrors ExportSpaces: creates shared fields + asset types, then imports each space. * Returns combined uidMap, urlMap, and spaceMappings for the bridge module. */ @@ -64,13 +64,13 @@ export class ImportSpaces { mapperUrlFileName: configOptions.mapperUrlFileName, mapperSpaceUidFileName: configOptions.mapperSpaceUidFileName, }; - const apiConfig: AssetManagementAPIConfig = { - baseURL: configOptions.assetManagementUrl, + const apiConfig: CSAssetsAPIConfig = { + baseURL: configOptions.csAssetsUrl, headers: { organization_uid: org_uid }, context, }; - log.debug('Starting Asset Management import process...', context); + log.debug('Starting Contentstack Assets import process...', context); // Discover space directories let spaceDirs: string[] = []; @@ -86,10 +86,18 @@ export class ImportSpaces { log.warn(`Could not read spaces root path ${spacesRootPath}: ${e}`, context); } - const totalSteps = 2 + spaceDirs.length * 2; const progress = this.createProgress(); - progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps); - progress.startProcess(AM_MAIN_PROCESS_NAME); + // Multibar layout: two shared bootstrap rows + one row per space directory. + // Per-space totals start at 1 and are bumped to (folderCount + assetCount) + // inside ImportAssets.start once we know the counts for that space. + progress.addProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, 1); + progress.addProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, 1); + const spaceProcessNames = new Map(); + for (const spaceUid of spaceDirs) { + const spaceProcess = getSpaceProcessName(spaceUid); + spaceProcessNames.set(spaceUid, spaceProcess); + progress.addProcess(spaceProcess, 1); + } const allUidMap: Record = {}; const allUrlMap: Record = {}; @@ -102,7 +110,7 @@ export class ImportSpaces { // Space UIDs already present in the target org — reuse when export dir name matches a uid here. const existingSpaceUids = new Set(); try { - const adapterForList = new AssetManagementAdapter(apiConfig); + const adapterForList = new CSAssetsAdapter(apiConfig); await adapterForList.init(); const { spaces } = await adapterForList.listSpaces(); for (const s of spaces) { @@ -114,30 +122,43 @@ export class ImportSpaces { } try { - log.info('Started Asset Management import', context); + log.info('Started Contentstack Assets import', context); // 1. Import shared fields - progress.updateStatus(`Importing shared fields...`, AM_MAIN_PROCESS_NAME); + progress.startProcess(PROCESS_NAMES.AM_IMPORT_FIELDS); const fieldsImporter = new ImportFields(apiConfig, importContext); fieldsImporter.setParentProgressManager(progress); - await fieldsImporter.start(); + try { + await fieldsImporter.start(); + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, true); + } catch (e) { + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, false); + throw e; + } // 2. Import shared asset types - progress.updateStatus('Importing shared asset types...', AM_MAIN_PROCESS_NAME); + progress.startProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); const assetTypesImporter = new ImportAssetTypes(apiConfig, importContext); assetTypesImporter.setParentProgressManager(progress); - await assetTypesImporter.start(); + try { + await assetTypesImporter.start(); + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, true); + } catch (e) { + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, false); + throw e; + } // 3. Import each space — continue on failure so partially-imported data is never lost for (const spaceUid of spaceDirs) { const spaceDir = join(spacesRootPath, spaceUid); - progress.updateStatus(`Importing space: ${spaceUid}...`, AM_MAIN_PROCESS_NAME); + const spaceProcess = spaceProcessNames.get(spaceUid)!; + progress.startProcess(spaceProcess); log.debug(`Importing space: ${spaceUid}`, context); try { const workspaceImporter = new ImportWorkspace(apiConfig, importContext); workspaceImporter.setParentProgressManager(progress); - const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids); + const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids, spaceProcess); // Newly created spaces get a new uid — add so later iterations in this run see it. existingSpaceUids.add(result.newSpaceUid); @@ -152,17 +173,13 @@ export class ImportSpaces { isDefault: result.isDefault, }); + progress.completeProcess(spaceProcess, true); log.debug(`Imported space ${spaceUid} → ${result.newSpaceUid}`, context); spacesSucceeded += 1; } catch (err) { hasFailures = true; spacesFailed += 1; - progress.tick( - false, - `space: ${spaceUid}`, - (err as Error)?.message ?? 'Failed to import space', - AM_MAIN_PROCESS_NAME, - ); + progress.completeProcess(spaceProcess, false); log.warn(`Failed to import space ${spaceUid}: ${err}`, context); } } @@ -178,18 +195,24 @@ export class ImportSpaces { await writeFile(join(mapperDir, uidFile), JSON.stringify(allUidMap), 'utf8'); await writeFile(join(mapperDir, urlFile), JSON.stringify(allUrlMap), 'utf8'); await writeFile(join(mapperDir, spaceUidFile), JSON.stringify(allSpaceUidMap), 'utf8'); - log.debug('Wrote AM 2.0 mapper files (uid, url, space-uid)', context); + log.debug('Wrote CS Assets mapper files (uid, url, space-uid)', context); } - progress.completeProcess(AM_MAIN_PROCESS_NAME, !hasFailures); log.info( - `Asset Management import finished: ${spacesSucceeded} space(s) succeeded, ${spacesFailed} failed, ${spaceDirs.length} attempted.`, + `Contentstack Assets import finished: ${spacesSucceeded} space(s) succeeded, ${spacesFailed} failed, ${spaceDirs.length} attempted.`, + context, + ); + log.debug( + `Contentstack Assets import completed (hasFailures=${hasFailures})`, context, ); - log.debug('Asset Management 2.0 import completed', context); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); - handleAndLogError(err, { ...(context as Record) }, 'Asset Management import failed'); + // Mark any spaces that hadn't been processed as failed so the multibar + // doesn't leave dangling pending rows when the bootstrap phase throws. + for (const [, spaceProcess] of spaceProcessNames) { + progress.completeProcess(spaceProcess, false); + } + handleAndLogError(err, { ...(context as Record) }, 'Contentstack Assets import failed'); throw err; } @@ -203,7 +226,7 @@ export class ImportSpaces { } const logConfig = configHandler.get('log') || {}; const showConsoleLogs = logConfig.showConsoleLogs ?? false; - this.progressManager = CLIProgressManager.createNested(AM_MAIN_PROCESS_NAME, showConsoleLogs); + this.progressManager = CLIProgressManager.createNested(CS_ASSETS_MAIN_PROCESS_NAME, showConsoleLogs); return this.progressManager; } } diff --git a/packages/contentstack-asset-management/src/import/workspaces.ts b/packages/contentstack-asset-management/src/import/workspaces.ts index e042b1f3b..0b31fb6e9 100644 --- a/packages/contentstack-asset-management/src/import/workspaces.ts +++ b/packages/contentstack-asset-management/src/import/workspaces.ts @@ -2,10 +2,9 @@ import { join } from 'node:path'; import { readFileSync } from 'node:fs'; import { log } from '@contentstack/cli-utilities'; -import type { AssetManagementAPIConfig, ImportContext, SpaceMapping } from '../types/asset-management-api'; -import { AssetManagementImportAdapter } from './base'; +import type { CSAssetsAPIConfig, ImportContext, SpaceMapping } from '../types/cs-assets-api'; +import { CSAssetsImportAdapter } from './base'; import ImportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; type WorkspaceResult = SpaceMapping & { uidMap: Record; @@ -13,23 +12,36 @@ type WorkspaceResult = SpaceMapping & { }; /** - * Handles import for a single AM 2.0 space directory. + * Handles import for a single CS Assets space directory. * Reads `metadata.json`, creates the space in the target org when its uid is not * already present, or reuses the existing space and emits identity mappers only. * Returns the SpaceMapping plus UID/URL maps for the mapper files. */ -export default class ImportWorkspace extends AssetManagementImportAdapter { - constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { +export default class ImportWorkspace extends CSAssetsImportAdapter { + constructor(apiConfig: CSAssetsAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } + /** + * Run the import pipeline for a single space. + * + * The optional `spaceProcessName` is the multibar row label that ticks + * (folder creates + per-asset uploads) should land on. The orchestrator + * passes the per-space row produced by `getSpaceProcessName`; if omitted the + * default {@link processName} is used so direct callers keep working. + */ async start( oldSpaceUid: string, spaceDir: string, existingSpaceUids: Set = new Set(), + spaceProcessName?: string, ): Promise { await this.init(); + if (spaceProcessName) { + this.setProcessName(spaceProcessName); + } + log.debug(`Starting import for AM space directory ${oldSpaceUid}`, this.importContext.context); // Read exported metadata @@ -48,16 +60,21 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { const assetsImporter = new ImportAssets(this.apiConfig, this.importContext); if (this.progressOrParent) assetsImporter.setParentProgressManager(this.progressOrParent); + if (spaceProcessName) { + assetsImporter.setProcessName(spaceProcessName); + } // Reuse: target org already has a space with the same uid as the export directory. if (existingSpaceUids.has(oldSpaceUid)) { log.info( - `Reusing existing AM space "${oldSpaceUid}" (uid matches export directory); skipping create and upload.`, + `Reusing existing Asset space "${oldSpaceUid}" (uid matches export directory); skipping create and upload.`, this.importContext.context, ); const newSpaceUid = oldSpaceUid; const { uidMap, urlMap } = await assetsImporter.buildIdentityMappersFromExport(spaceDir); - this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid} (reused)`, null, PROCESS_NAMES.AM_SPACE_METADATA); + // Reused spaces do no folder/asset work; tick the per-space row once so it + // completes in the multibar. + this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid} (reused)`, null); return { oldSpaceUid, newSpaceUid, @@ -75,7 +92,6 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { const newSpaceUid = space.uid; log.debug(`Created space ${newSpaceUid} (old: ${oldSpaceUid})`, this.importContext.context); - this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid}`, null, PROCESS_NAMES.AM_SPACE_METADATA); const { uidMap, urlMap } = await assetsImporter.start(newSpaceUid, spaceDir); diff --git a/packages/contentstack-asset-management/src/types/asset-management-api.ts b/packages/contentstack-asset-management/src/types/cs-assets-api.ts similarity index 93% rename from packages/contentstack-asset-management/src/types/asset-management-api.ts rename to packages/contentstack-asset-management/src/types/cs-assets-api.ts index 40423da89..0d26bc669 100644 --- a/packages/contentstack-asset-management/src/types/asset-management-api.ts +++ b/packages/contentstack-asset-management/src/types/cs-assets-api.ts @@ -106,9 +106,9 @@ export type AssetTypesResponse = { }; /** - * Configuration for AssetManagementAdapter constructor. + * Configuration for CSAssetsAdapter constructor. */ -export type AssetManagementAPIConfig = { +export type CSAssetsAPIConfig = { baseURL: string; headers?: Record; /** Optional context for logging (e.g. exportConfig.context) */ @@ -116,10 +116,10 @@ export type AssetManagementAPIConfig = { }; /** - * Adapter interface for Asset Management API calls. + * Adapter interface for Contentstack Assets API calls. * Used by export and (future) import. */ -export interface IAssetManagementAdapter { +export interface ICSAssetsAdapter { init(): Promise; listSpaces(): Promise; getSpace(spaceUid: string): Promise; @@ -136,7 +136,7 @@ export type AssetManagementExportOptions = { linkedWorkspaces: LinkedWorkspace[]; exportDir: string; branchName: string; - assetManagementUrl: string; + csAssetsUrl: string; org_uid: string; context?: Record; /** When true, the AM package will add authtoken to asset download URLs. */ @@ -152,7 +152,7 @@ export type AssetManagementExportOptions = { */ chunkFileSizeMb?: number; /** - * Max parallel AM API/export tasks for export (shared module bootstrap default). + * Max parallel CS Assets API/export tasks for export (shared module bootstrap default). */ apiConcurrency?: number; /** @@ -184,13 +184,13 @@ export type ImportContext = { /** Optional logging context (same shape as ExportConfig.context). */ context?: Record; /** - * Max parallel AM API calls for import (fields, asset types, and default for folders/uploads). + * Max parallel CS Assets API calls for import (fields, asset types, and default for folders/uploads). * Set from `ImportSpacesOptions.apiConcurrency` (or host wiring). */ apiConcurrency?: number; - /** Overrides parallel limit for asset uploads when set (import `modules['asset-management'].uploadAssetsConcurrency`). */ + /** Overrides parallel limit for asset uploads when set (import `modules['cs-assets'].uploadAssetsConcurrency`). */ uploadAssetsConcurrency?: number; - /** Overrides parallel limit for folder creation batches when set (import `modules['asset-management'].importFoldersConcurrency`). */ + /** Overrides parallel limit for folder creation batches when set (import `modules['cs-assets'].importFoldersConcurrency`). */ importFoldersConcurrency?: number; /** Relative dir under content dir for AM export root (e.g. `spaces`). */ spacesDirName?: string; @@ -217,8 +217,8 @@ export type ImportContext = { export type ImportSpacesOptions = { /** Absolute path to the root content / backup directory. */ contentDir: string; - /** AM 2.0 base URL (e.g. "https://am.contentstack.io"). */ - assetManagementUrl: string; + /** CS Assets base URL (e.g. "https://am.contentstack.io"). */ + csAssetsUrl: string; org_uid: string; apiKey: string; host: string; diff --git a/packages/contentstack-asset-management/src/types/index.ts b/packages/contentstack-asset-management/src/types/index.ts index c673e1893..e8d39e6f1 100644 --- a/packages/contentstack-asset-management/src/types/index.ts +++ b/packages/contentstack-asset-management/src/types/index.ts @@ -1,2 +1,2 @@ -export * from './asset-management-api'; +export * from './cs-assets-api'; export * from './export-types'; diff --git a/packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts b/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts similarity index 89% rename from packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts rename to packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts index 7e3bc0cbc..dcdf26a11 100644 --- a/packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts +++ b/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import { HttpClient, log, authenticationHandler, handleAndLogError } from '@contentstack/cli-utilities'; import type { - AssetManagementAPIConfig, + CSAssetsAPIConfig, AssetTypesResponse, CreateAssetMetadata, CreateAssetTypePayload, @@ -11,24 +11,24 @@ import type { CreateFolderPayload, CreateSpacePayload, FieldsResponse, - IAssetManagementAdapter, + ICSAssetsAdapter, Space, SpaceResponse, SpacesListResponse, -} from '../types/asset-management-api'; +} from '../types/cs-assets-api'; -export class AssetManagementAdapter implements IAssetManagementAdapter { - private readonly config: AssetManagementAPIConfig; +export class CSAssetsAdapter implements ICSAssetsAdapter { + private readonly config: CSAssetsAPIConfig; private readonly apiClient: HttpClient; - constructor(config: AssetManagementAPIConfig) { + constructor(config: CSAssetsAPIConfig) { this.config = config; this.apiClient = new HttpClient(); const baseURL = config.baseURL?.replace(/\/$/, '') ?? ''; this.apiClient.baseUrl(baseURL); const defaultHeaders = { Accept: 'application/json', 'x-cs-api-version': '4' }; this.apiClient.headers(config.headers ? { ...defaultHeaders, ...config.headers } : defaultHeaders); - log.debug('AssetManagementAdapter initialized', config.context); + log.debug('CSAssetsAdapter initialized', config.context); } /** @@ -67,7 +67,7 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { } /** - * Normalize AM API failures into a consistent error message with optional cause and body snippet. + * Normalize CS Assets API failures into a consistent error message with optional cause and body snippet. */ private normalizeAmGetFailure(details: { path: string; @@ -77,7 +77,7 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { bodySnippet?: string; }): Error { const { path, status, cause, bodySnippet } = details; - let message = `AM API GET failed: path ${path}`; + let message = `CS Assets API GET failed: path ${path}`; if (status) message += ` (status ${status})`; if (cause && cause instanceof Error) { message += ` - ${cause.message}`; @@ -122,7 +122,7 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { } return response.data as T; } catch (error) { - if (error instanceof Error && error.message.includes('AM API GET failed')) { + if (error instanceof Error && error.message.includes('CS Assets API GET failed')) { throw error; } throw this.normalizeAmGetFailure({ @@ -135,7 +135,7 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { async init(): Promise { try { - log.debug('Initializing Asset Management adapter...', this.config.context); + log.debug('Initializing Contentstack Assets adapter...', this.config.context); await authenticationHandler.getAuthDetails(); const token = authenticationHandler.accessToken; log.debug( @@ -144,12 +144,12 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { ); const authHeader = authenticationHandler.isOauthEnabled ? { authorization: token } : { access_token: token }; this.apiClient.headers(this.config.headers ? { ...authHeader, ...this.config.headers } : authHeader); - log.debug('Asset Management adapter initialization completed', this.config.context); + log.debug('Contentstack Assets adapter initialization completed', this.config.context); } catch (error: unknown) { handleAndLogError( error as Error, this.config.context ? { ...(this.config.context as Record) } : {}, - 'Asset Management adapter initialization failed', + 'Contentstack Assets adapter initialization failed', ); throw error; } @@ -260,17 +260,17 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { const text = await response.text().catch(() => ''); const bodySnippet = this.formatResponseBodyForError(text); throw new Error( - `AM API POST failed: status ${response.status} path ${path}${ + `CS Assets API POST failed: status ${response.status} path ${path}${ bodySnippet ? `\nResponse: ${bodySnippet}` : '' }`, ); } return response.json() as Promise; } catch (error) { - if (error instanceof Error && error.message.includes('AM API POST failed')) { + if (error instanceof Error && error.message.includes('CS Assets API POST failed')) { throw error; } - throw new Error(`AM API POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`CS Assets API POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`); } } @@ -289,18 +289,18 @@ export class AssetManagementAdapter implements IAssetManagementAdapter { const text = await response.text().catch(() => ''); const bodySnippet = this.formatResponseBodyForError(text); throw new Error( - `AM API multipart POST failed: status ${response.status} path ${path}${ + `CS Assets API multipart POST failed: status ${response.status} path ${path}${ bodySnippet ? `\nResponse: ${bodySnippet}` : '' }`, ); } return response.json() as Promise; } catch (error) { - if (error instanceof Error && error.message.includes('AM API multipart POST failed')) { + if (error instanceof Error && error.message.includes('CS Assets API multipart POST failed')) { throw error; } throw new Error( - `AM API multipart POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`, + `CS Assets API multipart POST failed: path ${path} - ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/packages/contentstack-asset-management/src/utils/index.ts b/packages/contentstack-asset-management/src/utils/index.ts index f84b94cb1..262cfe9ef 100644 --- a/packages/contentstack-asset-management/src/utils/index.ts +++ b/packages/contentstack-asset-management/src/utils/index.ts @@ -1,4 +1,4 @@ -export { AssetManagementAdapter } from './asset-management-api-adapter'; +export { CSAssetsAdapter } from './cs-assets-api-adapter'; export { CHUNK_FILE_SIZE_MB, FALLBACK_AM_CHUNK_FILE_SIZE_MB } from '../constants'; export { forEachChunkedJsonStore, forEachChunkRecordsFromFs } from './chunked-json-reader'; export { diff --git a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts index af052e2db..fd033432b 100644 --- a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts @@ -2,14 +2,13 @@ import { expect } from 'chai'; import sinon from 'sinon'; import ExportAssetTypes from '../../../src/export/asset-types'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { PROCESS_NAMES } from '../../../src/constants/index'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; -import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../../../src/types/cs-assets-api'; import type { ExportContext } from '../../../src/types/export-types'; describe('ExportAssetTypes', () => { - const apiConfig: AssetManagementAPIConfig = { + const apiConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -31,9 +30,9 @@ describe('ExportAssetTypes', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'tick' as any); }); afterEach(() => { @@ -54,7 +53,7 @@ describe('ExportAssetTypes', () => { const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); - const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + const writeStub = (CSAssetsExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; const args = writeStub.firstCall.args; expect(args[0]).to.equal(assetTypesDir); expect(args[1]).to.equal('asset-types.json'); @@ -72,17 +71,23 @@ describe('ExportAssetTypes', () => { const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); - const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + const writeStub = (CSAssetsExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should tick with success=true, the asset types process name, and null error', async () => { + it('should tick once with the asset_types summary label and null error after writing', async () => { sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_ASSET_TYPES, null]); + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.callCount).to.equal(1); + const [success, label, error] = tickStub.firstCall.args; + expect(success).to.be.true; + // Label format is `asset_types ()` so the shared row carries a count + // summary; exact count comes from the mocked asset-types response. + expect(String(label)).to.match(/^asset_types \(\d+\)$/); + expect(error).to.be.null; }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/assets.test.ts b/packages/contentstack-asset-management/test/unit/export/assets.test.ts index ab6b831d4..f6a4bc61e 100644 --- a/packages/contentstack-asset-management/test/unit/export/assets.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/assets.test.ts @@ -3,9 +3,9 @@ import sinon from 'sinon'; import { configHandler } from '@contentstack/cli-utilities'; import ExportAssets from '../../../src/export/assets'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; -import type { AssetManagementAPIConfig, LinkedWorkspace } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig, LinkedWorkspace } from '../../../src/types/cs-assets-api'; import type { ExportContext } from '../../../src/types/export-types'; const foldersData = [{ uid: 'folder-1', name: 'Images' }]; @@ -18,7 +18,7 @@ const assetsResponseWithItems = { const emptyAssetsResponse = { items: [] as any[] }; describe('ExportAssets', () => { - const apiConfig: AssetManagementAPIConfig = { + const apiConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -48,10 +48,10 @@ describe('ExportAssets', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); - sinon.stub(AssetManagementExportAdapter.prototype, 'updateStatus' as any); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'tick' as any); + sinon.stub(CSAssetsExportAdapter.prototype, 'updateStatus' as any); fetchStub = sinon.stub(globalThis, 'fetch'); }); @@ -89,7 +89,7 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + const writeStub = (CSAssetsExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; const args = writeStub.firstCall.args; expect(args[1]).to.equal('assets.json'); expect(args[2]).to.equal('assets'); @@ -105,12 +105,12 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); expect(fetchStub.callCount).to.equal(0); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick).to.be.undefined; + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(0); }); - it('should tick with success=false and the error message on download failure', async () => { + it('should tick per failed asset with success=false and the error message on download failure', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.rejects(new Error('network failure')); @@ -118,13 +118,17 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.equal('network failure'); + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + // Per-asset tick: one failure entry per attempted download. + expect(assetTicks.length).to.be.greaterThan(0); + for (const t of assetTicks) { + expect(t.args[0]).to.be.false; + expect(t.args[2]).to.equal('network failure'); + } }); - it('should tick with success=true and null error on successful downloads', async () => { + it('should tick per asset with success=true and null error on successful downloads', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.callsFake(async () => makeFetchResponse() as any); @@ -132,10 +136,14 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; - expect(downloadTick!.args[2]).to.be.null; + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + // One successful tick per asset in the workspace. + expect(assetTicks).to.have.length(assetsResponseWithItems.items.length); + for (const t of assetTicks) { + expect(t.args[0]).to.be.true; + expect(t.args[2]).to.be.null; + } }); it('should skip assets that have neither a url nor a uid', async () => { @@ -167,10 +175,11 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a.png'); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; - expect(downloadTick!.args[2]).to.be.null; + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.true; + expect(assetTicks[0].args[2]).to.be.null; }); it('should download assets that use file_name, and fall back to "asset" when both names are absent', async () => { @@ -190,9 +199,10 @@ describe('ExportAssets', () => { expect(fetchStub.callCount).to.equal(2); expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a1.pdf'); expect(fetchStub.secondCall.args[0]).to.equal('https://cdn.example.com/a2.bin'); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(2); + for (const t of assetTicks) expect(t.args[0]).to.be.true; }); it('should append authtoken to URL when securedAssets is true', async () => { @@ -237,10 +247,11 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.include('403'); + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.false; + expect(assetTicks[0].args[2]).to.include('403'); }); it('should tick with success=false and "No response body" when body is null', async () => { @@ -253,10 +264,11 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.equal('No response body'); + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.false; + expect(assetTicks[0].args[2]).to.equal('No response body'); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/base.test.ts b/packages/contentstack-asset-management/test/unit/export/base.test.ts index c65b04021..08993eddf 100644 --- a/packages/contentstack-asset-management/test/unit/export/base.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/base.test.ts @@ -2,12 +2,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { FsUtility, CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; -import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../../../src/types/cs-assets-api'; import type { ExportContext } from '../../../src/types/export-types'; -class TestAdapter extends AssetManagementExportAdapter { +class TestAdapter extends CSAssetsExportAdapter { public callCreateNestedProgress(name: string) { return this.createNestedProgress(name); } @@ -20,7 +20,7 @@ class TestAdapter extends AssetManagementExportAdapter { public callCompleteProcess(name: string, success: boolean) { return this.completeProcess(name, success); } - public callWriteItemsToChunkedJson(...args: Parameters) { + public callWriteItemsToChunkedJson(...args: Parameters) { return this.writeItemsToChunkedJson(...args); } public getProgressOrParent() { @@ -37,8 +37,8 @@ class TestAdapter extends AssetManagementExportAdapter { } } -describe('AssetManagementExportAdapter (base)', () => { - const apiConfig: AssetManagementAPIConfig = { +describe('CSAssetsExportAdapter (base)', () => { + const apiConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -48,7 +48,7 @@ describe('AssetManagementExportAdapter (base)', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); }); afterEach(() => { diff --git a/packages/contentstack-asset-management/test/unit/export/fields.test.ts b/packages/contentstack-asset-management/test/unit/export/fields.test.ts index a039dcb75..cb99ef844 100644 --- a/packages/contentstack-asset-management/test/unit/export/fields.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/fields.test.ts @@ -2,14 +2,13 @@ import { expect } from 'chai'; import sinon from 'sinon'; import ExportFields from '../../../src/export/fields'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { PROCESS_NAMES } from '../../../src/constants/index'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; -import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../../../src/types/cs-assets-api'; import type { ExportContext } from '../../../src/types/export-types'; describe('ExportFields', () => { - const apiConfig: AssetManagementAPIConfig = { + const apiConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -31,9 +30,9 @@ describe('ExportFields', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'tick' as any); }); afterEach(() => { @@ -54,7 +53,7 @@ describe('ExportFields', () => { const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); - const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + const writeStub = (CSAssetsExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; const args = writeStub.firstCall.args; expect(args[0]).to.equal(fieldsDir); expect(args[1]).to.equal('fields.json'); @@ -72,17 +71,23 @@ describe('ExportFields', () => { const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); - const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + const writeStub = (CSAssetsExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should tick with success=true, the fields process name, and null error', async () => { + it('should tick once with the fields summary label and null error after writing', async () => { sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_FIELDS, null]); + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.callCount).to.equal(1); + const [success, label, error] = tickStub.firstCall.args; + expect(success).to.be.true; + // Label format is `fields ()` so the shared row carries a count + // summary; exact count comes from the mocked fields response. + expect(String(label)).to.match(/^fields \(\d+\)$/); + expect(error).to.be.null; }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts index 72e0910c9..ec30409e4 100644 --- a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts @@ -6,10 +6,10 @@ import { ExportSpaces, exportSpaceStructure } from '../../../src/export/spaces'; import ExportAssetTypes from '../../../src/export/asset-types'; import ExportFields from '../../../src/export/fields'; import ExportWorkspace from '../../../src/export/workspaces'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { AM_MAIN_PROCESS_NAME } from '../../../src/constants/index'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; +import { PROCESS_NAMES, getSpaceProcessName } from '../../../src/constants/index'; -import type { AssetManagementExportOptions, LinkedWorkspace } from '../../../src/types/asset-management-api'; +import type { AssetManagementExportOptions, LinkedWorkspace } from '../../../src/types/cs-assets-api'; describe('ExportSpaces', () => { const baseOptions: AssetManagementExportOptions = { @@ -19,7 +19,7 @@ describe('ExportSpaces', () => { ], exportDir: '/tmp/export', branchName: 'main', - assetManagementUrl: 'https://am.example.com', + csAssetsUrl: 'https://am.example.com', org_uid: 'org-1', }; @@ -32,7 +32,7 @@ describe('ExportSpaces', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); sinon.stub(configHandler, 'get').returns({ showConsoleLogs: false }); sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress as any); sinon.stub(ExportAssetTypes.prototype, 'start').resolves(); @@ -42,8 +42,11 @@ describe('ExportSpaces', () => { sinon.stub(ExportWorkspace.prototype, 'start').resolves(); sinon.stub(ExportWorkspace.prototype, 'setParentProgressManager'); + fakeProgress.addProcess.resetHistory(); fakeProgress.addProcess.returnsThis(); + fakeProgress.startProcess.resetHistory(); fakeProgress.startProcess.returnsThis(); + fakeProgress.updateStatus.resetHistory(); fakeProgress.updateStatus.returnsThis(); fakeProgress.tick.reset(); fakeProgress.completeProcess.reset(); @@ -105,31 +108,46 @@ describe('ExportSpaces', () => { expect(wsStub.secondCall.args[0]).to.deep.include({ uid: 'ws-2', space_uid: 'space-2' }); }); - it('should register and complete the progress process with success', async () => { - const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; // 10 + it('should register one shared row per bootstrap phase plus one row per space, and complete each on success', async () => { const exporter = new ExportSpaces(baseOptions); await exporter.start(); - expect(fakeProgress.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); - expect(fakeProgress.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); + const addProcessCalls = fakeProgress.addProcess.getCalls().map((c) => c.args); + // Shared bootstrap rows + one row per linked workspace. + expect(addProcessCalls).to.deep.equal([ + [PROCESS_NAMES.AM_FIELDS, 1], + [PROCESS_NAMES.AM_ASSET_TYPES, 1], + [getSpaceProcessName('space-1'), 1], + [getSpaceProcessName('space-2'), 1], + ]); + + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include.members([ + [PROCESS_NAMES.AM_FIELDS, true], + [PROCESS_NAMES.AM_ASSET_TYPES, true], + [getSpaceProcessName('space-1'), true], + [getSpaceProcessName('space-2'), true], + ]); }); - it('should mark progress as failed and re-throw when a workspace export errors', async () => { - (ExportWorkspace.prototype.start as sinon.SinonStub).rejects(new Error('workspace-error')); + it('should mark only the failing space row as failed and continue with remaining spaces', async () => { + const wsStub = ExportWorkspace.prototype.start as sinon.SinonStub; + wsStub.onFirstCall().rejects(new Error('workspace-error')); + wsStub.onSecondCall().resolves(); const exporter = new ExportSpaces(baseOptions); - try { - await exporter.start(); - expect.fail('should have thrown'); - } catch (err: any) { - expect(err.message).to.equal('workspace-error'); - } + // Per the plan, per-space failures must NOT abort the orchestrator — + // they're recorded on that space's row and the next space proceeds. + await exporter.start(); - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, false]); + expect(wsStub.callCount).to.equal(2); + + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include([getSpaceProcessName('space-1'), false]); + expect(completeArgs).to.deep.include([getSpaceProcessName('space-2'), true]); }); - it('should mark progress as failed and re-throw when shared bootstrap export errors', async () => { + it('should mark shared rows as failed and re-throw when shared bootstrap export errors', async () => { (ExportFields.prototype.start as sinon.SinonStub).rejects(new Error('shared-bootstrap-error')); const exporter = new ExportSpaces(baseOptions); @@ -140,7 +158,9 @@ describe('ExportSpaces', () => { expect(err.message).to.equal('shared-bootstrap-error'); } - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, false]); + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include([PROCESS_NAMES.AM_FIELDS, false]); + expect(completeArgs).to.deep.include([PROCESS_NAMES.AM_ASSET_TYPES, false]); }); it('should use the provided parentProgressManager instead of creating a new one', async () => { @@ -151,16 +171,20 @@ describe('ExportSpaces', () => { tick: sinon.stub(), completeProcess: sinon.stub(), }; - const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; const exporter = new ExportSpaces(baseOptions); exporter.setParentProgressManager(fakeParent as any); await exporter.start(); expect((CLIProgressManager.createNested as sinon.SinonStub).callCount).to.equal(0); - expect(fakeParent.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); - expect(fakeParent.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); - expect(fakeParent.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); + + const addProcessCalls = fakeParent.addProcess.getCalls().map((c) => c.args); + expect(addProcessCalls).to.deep.equal([ + [PROCESS_NAMES.AM_FIELDS, 1], + [PROCESS_NAMES.AM_ASSET_TYPES, 1], + [getSpaceProcessName('space-1'), 1], + [getSpaceProcessName('space-2'), 1], + ]); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts index 0a4503b04..212f79c94 100644 --- a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts @@ -3,13 +3,13 @@ import sinon from 'sinon'; import ExportWorkspace from '../../../src/export/workspaces'; import ExportAssets from '../../../src/export/assets'; -import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; -import type { AssetManagementAPIConfig, LinkedWorkspace, SpaceResponse } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig, LinkedWorkspace, SpaceResponse } from '../../../src/types/cs-assets-api'; import type { ExportContext } from '../../../src/types/export-types'; describe('ExportWorkspace', () => { - const apiConfig: AssetManagementAPIConfig = { + const apiConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -36,8 +36,8 @@ describe('ExportWorkspace', () => { }; beforeEach(() => { - sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); - sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + sinon.stub(CSAssetsExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'tick' as any); sinon.stub(ExportAssets.prototype, 'start').resolves(); sinon.stub(ExportAssets.prototype, 'setParentProgressManager'); }); @@ -55,13 +55,26 @@ describe('ExportWorkspace', () => { expect(getSpaceStub.firstCall.args[0]).to.equal(workspace.space_uid); }); - it('should tick success after writing metadata', async () => { + it('should NOT tick after writing metadata (per-space row is owned by ExportAssets)', async () => { sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); const exporter = new ExportWorkspace(apiConfig, exportContext); await exporter.start(workspace, spaceDir, branchName); - const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, `space: ${workspace.space_uid}`, null]); + // The per-space progress row's total is folder + metadata + downloads — + // all owned by ExportAssets. The workspace metadata.json write is a + // fixed bootstrap step and intentionally does not consume a tick. + const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.callCount).to.equal(0); + }); + + it('should forward spaceProcessName to the assets exporter via setProcessName', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const setProcessNameStub = sinon.stub(ExportAssets.prototype, 'setProcessName' as any); + + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName, 'Space space-uid-1'); + + expect(setProcessNameStub.firstCall.args[0]).to.equal('Space space-uid-1'); }); it('should delegate to ExportAssets.start with workspace and spaceDir', async () => { diff --git a/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts b/packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts similarity index 85% rename from packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts rename to packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts index f7e774912..20f122d82 100644 --- a/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts +++ b/packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts @@ -2,12 +2,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { HttpClient, authenticationHandler } from '@contentstack/cli-utilities'; -import { AssetManagementAdapter } from '../../../src/utils/asset-management-api-adapter'; +import { CSAssetsAdapter } from '../../../src/utils/cs-assets-api-adapter'; -import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { CSAssetsAPIConfig } from '../../../src/types/cs-assets-api'; -describe('AssetManagementAdapter', () => { - const baseConfig: AssetManagementAPIConfig = { +describe('CSAssetsAdapter', () => { + const baseConfig: CSAssetsAPIConfig = { baseURL: 'https://am.example.com', headers: { organization_uid: 'org-1' }, }; @@ -31,12 +31,12 @@ describe('AssetManagementAdapter', () => { describe('constructor', () => { it('should set the baseURL with trailing slash stripped', () => { - new AssetManagementAdapter({ baseURL: 'https://am.example.com/' }); + new CSAssetsAdapter({ baseURL: 'https://am.example.com/' }); expect(baseUrlStub.firstCall.args[0]).to.equal('https://am.example.com'); }); it('should set default headers with x-cs-api-version when no extra headers provided', () => { - new AssetManagementAdapter({ baseURL: 'https://am.example.com' }); + new CSAssetsAdapter({ baseURL: 'https://am.example.com' }); const allHeaderArgs = headersStub.getCalls().map((c) => c.args[0]); const apiVersionCall = allHeaderArgs.find((h) => 'x-cs-api-version' in h); expect(apiVersionCall).to.exist; @@ -45,7 +45,7 @@ describe('AssetManagementAdapter', () => { }); it('should merge extra headers with default headers', () => { - new AssetManagementAdapter(baseConfig); + new CSAssetsAdapter(baseConfig); const allHeaderArgs = headersStub.getCalls().map((c) => c.args[0]); const apiVersionCall = allHeaderArgs.find((h) => 'x-cs-api-version' in h); expect(apiVersionCall).to.exist; @@ -54,14 +54,14 @@ describe('AssetManagementAdapter', () => { }); it('should handle empty baseURL gracefully', () => { - new AssetManagementAdapter({ baseURL: '' }); + new CSAssetsAdapter({ baseURL: '' }); expect(baseUrlStub.firstCall.args[0]).to.equal(''); }); }); describe('init', () => { it('should set access_token header when OAuth is disabled', async () => { - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.init(); const authCallArgs = headersStub.getCalls().map((c) => c.args[0]); @@ -83,7 +83,7 @@ describe('AssetManagementAdapter', () => { it('should set authorization header', async () => { const capturedHeaders = HttpClient.prototype.headers as sinon.SinonStub; - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.init(); const authCallArgs = capturedHeaders.getCalls().map((c) => c.args[0]); @@ -95,7 +95,7 @@ describe('AssetManagementAdapter', () => { it('should re-throw errors from getAuthDetails', async () => { (authenticationHandler.getAuthDetails as sinon.SinonStub).rejects(new Error('auth-failed')); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); try { await adapter.init(); @@ -106,7 +106,7 @@ describe('AssetManagementAdapter', () => { }); it('should merge config headers with auth header when config.headers is present', async () => { - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.init(); const capturedHeaders = headersStub.getCalls().map((c) => c.args[0]); @@ -118,7 +118,7 @@ describe('AssetManagementAdapter', () => { describe('getSpace', () => { it('should GET /api/spaces/{spaceUid}?addl_fields=... and return the space', async () => { getStub.resolves({ status: 200, data: { space: { uid: 'sp-1' } } }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); const result = await adapter.getSpace('sp-1'); const path = getStub.firstCall.args[0] as string; @@ -129,7 +129,7 @@ describe('AssetManagementAdapter', () => { it('should throw when response status is non-2xx', async () => { getStub.resolves({ status: 404, data: null }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); try { await adapter.getSpace('missing-space'); @@ -144,7 +144,7 @@ describe('AssetManagementAdapter', () => { it('should GET /api/fields and return the response data', async () => { const fieldsResponse = { count: 1, relation: 'org', fields: [{ uid: 'f1' }] }; getStub.resolves({ status: 200, data: fieldsResponse }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); const result = await adapter.getWorkspaceFields('sp-1'); expect(getStub.firstCall.args[0]).to.equal('/api/fields'); @@ -155,7 +155,7 @@ describe('AssetManagementAdapter', () => { describe('getWorkspaceAssets', () => { it('should GET /api/spaces/{spaceUid}/assets', async () => { getStub.resolves({ status: 200, data: { items: [] } }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.getWorkspaceAssets('sp-1'); expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/assets'); @@ -163,7 +163,7 @@ describe('AssetManagementAdapter', () => { it('should URL-encode the spaceUid in the path', async () => { getStub.resolves({ status: 200, data: { items: [] } }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.getWorkspaceAssets('sp uid/special'); const path = getStub.firstCall.args[0] as string; @@ -174,7 +174,7 @@ describe('AssetManagementAdapter', () => { describe('getWorkspaceFolders', () => { it('should GET /api/spaces/{spaceUid}/folders', async () => { getStub.resolves({ status: 200, data: [] }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.getWorkspaceFolders('sp-1'); expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/folders'); @@ -185,7 +185,7 @@ describe('AssetManagementAdapter', () => { it('should GET /api/asset_types?include_fields=true and return the response data', async () => { const atResponse = { count: 1, relation: 'org', asset_types: [{ uid: 'at1' }] }; getStub.resolves({ status: 200, data: atResponse }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); const result = await adapter.getWorkspaceAssetTypes('sp-1'); const path = getStub.firstCall.args[0] as string; @@ -198,7 +198,7 @@ describe('AssetManagementAdapter', () => { describe('buildQueryString (via public methods)', () => { it('should encode array values as repeated key=value pairs', async () => { getStub.resolves({ status: 200, data: { space: { uid: 'sp-1' } } }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.getSpace('sp-1'); const path = getStub.firstCall.args[0] as string; @@ -208,7 +208,7 @@ describe('AssetManagementAdapter', () => { it('should return empty string and no "?" when params are empty', async () => { getStub.resolves({ status: 200, data: { count: 0, relation: '', fields: [] } }); - const adapter = new AssetManagementAdapter(baseConfig); + const adapter = new CSAssetsAdapter(baseConfig); await adapter.getWorkspaceFields('sp-1'); const path = getStub.firstCall.args[0] as string; diff --git a/packages/contentstack-audit/README.md b/packages/contentstack-audit/README.md index 7aec9c774..fb3ba7a12 100644 --- a/packages/contentstack-audit/README.md +++ b/packages/contentstack-audit/README.md @@ -157,5 +157,5 @@ DESCRIPTION Display help for csdx. ``` -_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.44/src/commands/help.ts)_ +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_ diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index c6c1a637b..ff375a65a 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -8,6 +8,20 @@ import values from 'lodash/values'; import { keys } from 'lodash'; import BaseClass from './base-class'; +/** + * Multibar row label for a single space. Bounded to 14 chars after the + * `Space ` prefix so CLIProgressManager.formatProcessName doesn't truncate the + * row mid-string. Mirrors the helper in `@contentstack/cli-cs-assets`. + */ +const SPACE_PROCESS_NAME_PREFIX = 'Space '; +const SPACE_PROCESS_NAME_MAX_UID_LEN = 14; +function getSpaceProcessName(spaceUid: string): string { + const safe = spaceUid ?? ''; + const trimmed = + safe.length > SPACE_PROCESS_NAME_MAX_UID_LEN ? safe.substring(0, SPACE_PROCESS_NAME_MAX_UID_LEN) : safe; + return `${SPACE_PROCESS_NAME_PREFIX}${trimmed}`; +} + /* The `Assets` class is responsible for scanning assets, looking for missing environment/locale references, and generating a report in JSON and CSV formats. */ export default class Assets extends BaseClass { @@ -24,6 +38,8 @@ export default class Assets extends BaseClass { public moduleName: keyof typeof auditConfig.moduleConfig; private fixOverwriteConfirmed: boolean | null = null; private resolvedBasePaths: Array<{ path: string; spaceId: string | null }> = []; + /** Map space dir name → the per-space multibar row label, or empty when single-space. */ + private spaceProcessNames: Map = new Map(); constructor({ fix, config, moduleName }: ModuleConstructorParam & CtConstructorParam) { super({ config }); @@ -71,15 +87,32 @@ export default class Assets extends BaseClass { await this.prerequisiteData(); }); - // Create progress manager if we have a total count - if (totalCount && totalCount > 0) { + // Resolve base paths up front so the progress UI can decide between a + // simple single-bar layout (legacy export) and a per-space multibar. + this.resolvedBasePaths = this.resolveAssetBasePaths(); + log.debug(`Resolved ${this.resolvedBasePaths.length} asset base path(s)`, this.config.auditContext); + + const isMultiSpace = + this.resolvedBasePaths.length > 1 || + (this.resolvedBasePaths.length === 1 && this.resolvedBasePaths[0].spaceId !== null); + + if (isMultiSpace) { + const progress = this.createNestedProgress(this.moduleName); + for (const { path, spaceId } of this.resolvedBasePaths) { + // Each space row's total = number of assets in that space; pre-counted + // from the chunked metadata so the bar shows real progress as ticks + // accumulate inside lookForReference. + const rowName = getSpaceProcessName(spaceId ?? 'unknown'); + this.spaceProcessNames.set(spaceId ?? path, rowName); + const spaceTotal = this.countAssetsInChunkedStore(path); + progress.addProcess(rowName, Math.max(1, spaceTotal)); + } + } else if (totalCount && totalCount > 0) { + // Legacy flat layout — single progress bar for the whole asset set. const progress = this.createSimpleProgress(this.moduleName, totalCount); progress.updateStatus('Validating asset references...'); } - this.resolvedBasePaths = this.resolveAssetBasePaths(); - log.debug(`Resolved ${this.resolvedBasePaths.length} asset base path(s)`, this.config.auditContext); - log.debug('Starting asset Reference, Environment and Locale validation', this.config.auditContext); await this.lookForReference(); @@ -250,8 +283,16 @@ export default class Assets extends BaseClass { cliux.print($t(auditMsg.AUDITING_SPACE, { spaceId }), { color: 'cyan' }); } - // Progress bar UX: update status label to reflect the current space - this.progressManager?.updateStatus?.(spaceId ? `Space: ${spaceId}` : 'Scanning assets...'); + // Multi-space layout: start the per-space row and route ticks below to it. + // Single-space (legacy) layout falls back to the existing simple progress + // bar with a status update. + const spaceProcessName = this.spaceProcessNames.get(spaceId ?? spacePath); + if (spaceProcessName) { + this.progressManager?.startProcess?.(spaceProcessName); + this.progressManager?.updateStatus?.(`Space: ${spaceId ?? 'assets'}`, spaceProcessName); + } else { + this.progressManager?.updateStatus?.(spaceId ? `Space: ${spaceId}` : 'Scanning assets...'); + } let fsUtility = new FsUtility({ basePath: spacePath, indexFileName: 'assets.json' }); let indexer = fsUtility.indexFileContent; @@ -332,7 +373,9 @@ export default class Assets extends BaseClass { ); if (this.progressManager) { - this.progressManager.tick(true, `asset: ${assetUid}`, null); + // Route the tick to the per-space row when multi-space, otherwise + // tick the single legacy progress bar (processName arg defaults). + this.progressManager.tick(true, `asset: ${assetUid}`, null, spaceProcessName); } if (this.fix) { @@ -345,6 +388,12 @@ export default class Assets extends BaseClass { await this.writeFixContent(`${spacePath}/${indexer[fileIndex]}`, this.assets); } } + + // Per-space row finished — close it so the multibar shows ✓ Complete + // and the next space (if any) starts cleanly. + if (spaceProcessName) { + this.progressManager?.completeProcess?.(spaceProcessName, true); + } } log.debug( @@ -354,4 +403,30 @@ export default class Assets extends BaseClass { this.config.auditContext, ); } + + /** + * Sum the asset count across all chunk metadata files for a given space's + * `assets/` directory. Used by `run` to seed each per-space progress row's + * total before validation begins. Falls back to walking chunk files if the + * aggregated `metadata.json` is unavailable (older exports). + */ + private countAssetsInChunkedStore(assetsDir: string): number { + try { + const fsUtility = new FsUtility({ basePath: assetsDir, indexFileName: 'assets.json' }); + const meta = fsUtility.getPlainMeta(); + let total = 0; + for (const value of Object.values(meta)) { + if (Array.isArray(value)) total += value.length; + } + if (total > 0) return total; + + // Fallback: count keys across each chunk file (slow path for legacy + // exports without metadata.json). + const indexer = fsUtility.indexFileContent ?? {}; + return Object.keys(indexer).length; + } catch (e) { + log.debug(`Could not pre-count assets in ${assetsDir}: ${e}`, this.config.auditContext); + return 0; + } + } } diff --git a/packages/contentstack-export/src/config/index.ts b/packages/contentstack-export/src/config/index.ts index da39dc24c..767334759 100644 --- a/packages/contentstack-export/src/config/index.ts +++ b/packages/contentstack-export/src/config/index.ts @@ -112,7 +112,7 @@ const config: DefaultConfig = { enableDownloadStatus: false, includeVersionedAssets: false, }, - 'asset-management': { + 'cs-assets': { chunkFileSizeMb: 1, apiConcurrency: 5, downloadAssetsConcurrency: 5, diff --git a/packages/contentstack-export/src/export/modules/assets.ts b/packages/contentstack-export/src/export/modules/assets.ts index a54a4b84f..6b79da510 100644 --- a/packages/contentstack-export/src/export/modules/assets.ts +++ b/packages/contentstack-export/src/export/modules/assets.ts @@ -60,56 +60,60 @@ export default class ExportAssets extends BaseClass { const linkedWorkspaces = this.exportConfig.linkedWorkspaces ?? []; if (linkedWorkspaces.length > 0) { - const assetManagementUrl = this.exportConfig.region?.assetManagementUrl; - if (!assetManagementUrl) { + const csAssetsUrl = this.exportConfig.region?.csAssetsUrl; + if (!csAssetsUrl) { handleAndLogError( new Error( - 'Asset Management URL is required for AM 2.0 export. Ensure your region is configured with assetManagementUrl.', + 'Contentstack Assets URL is required for CS Assets export. Ensure your region is configured with csAssetsUrl.', ), { ...this.exportConfig.context, message: - 'Asset Management URL is required for AM 2.0 export. Ensure your region is configured with assetManagementUrl.', + 'Contentstack Assets URL is required for CS Assets export. Ensure your region is configured with csAssetsUrl.', }, ); this.completeProgressWithMessage({ - moduleName: 'Asset Management 2.0', + moduleName: 'Contentstack Assets', customWarningMessage: - 'Asset Management 2.0 export was skipped: assetManagementUrl is not configured. AM 2.0 assets will not be exported.', + 'Contentstack Assets export was skipped: csAssetsUrl is not configured. CS Assets assets will not be exported.', context: this.exportConfig.context, }); cliux.print( - 'Asset Management URL is required for AM 2.0 export. Ensure your region is configured with assetManagementUrl.', + 'Contentstack Assets URL is required for CS Assets export. Ensure your region is configured with csAssetsUrl.', { color: 'yellow' }, ); return; } log.debug( - `Exporting with AM 2.0: ${assetManagementUrl} (linked_workspaces from exportConfig)`, + `Exporting with CS Assets: ${csAssetsUrl} (linked_workspaces from exportConfig)`, this.exportConfig.context, ); this.exportConfig.org_uid = this.exportConfig.org_uid || (await getOrgUid(this.exportConfig)); const progress = this.createNestedProgress(this.currentModuleName); try { - const assetManagementModuleConfig = this.exportConfig.modules['asset-management']; + const legacyModuleConfig = (this.exportConfig.modules as Record)['asset-management']; + const csAssetsModuleConfig = this.exportConfig.modules['cs-assets'] || legacyModuleConfig; + if (!this.exportConfig.modules['cs-assets'] && legacyModuleConfig) { + log.warn('Config key "modules.asset-management" is deprecated. Please rename it to "modules.cs-assets".'); + } const exporter = new ExportSpaces({ linkedWorkspaces, exportDir: this.exportConfig.exportDir, branchName: this.exportConfig.branchName || 'main', - assetManagementUrl, + csAssetsUrl, org_uid: this.exportConfig.org_uid ?? '', apiKey: this.exportConfig.apiKey, context: this.exportConfig.context as unknown as Record, securedAssets: this.exportConfig.securedAssets, - chunkFileSizeMb: assetManagementModuleConfig?.chunkFileSizeMb, - apiConcurrency: assetManagementModuleConfig?.apiConcurrency, - downloadAssetsConcurrency: assetManagementModuleConfig?.downloadAssetsConcurrency, + chunkFileSizeMb: csAssetsModuleConfig?.chunkFileSizeMb, + apiConcurrency: csAssetsModuleConfig?.apiConcurrency, + downloadAssetsConcurrency: csAssetsModuleConfig?.downloadAssetsConcurrency, }); exporter.setParentProgressManager(progress); await exporter.start(); this.completeProgressWithMessage(); } catch (error) { - this.completeProgress(false, (error as Error)?.message ?? 'Asset Management export failed'); + this.completeProgress(false, (error as Error)?.message ?? 'Contentstack Assets export failed'); throw error; } return; diff --git a/packages/contentstack-export/src/types/default-config.ts b/packages/contentstack-export/src/types/default-config.ts index 7fedadd61..433f420fb 100644 --- a/packages/contentstack-export/src/types/default-config.ts +++ b/packages/contentstack-export/src/types/default-config.ts @@ -96,10 +96,10 @@ export default interface DefaultConfig { includeVersionedAssets: boolean; dependencies?: Modules[]; }; - 'asset-management': { + 'cs-assets': { /** Passed to FsUtility chunkFileSize (MB) when writing chunked export JSON. */ chunkFileSizeMb: number; - /** Shared export concurrency fallback used by AM 2.0 export. */ + /** Shared export concurrency fallback used by CS Assets export. */ apiConcurrency: number; /** Parallel downloads per AM workspace export. */ downloadAssetsConcurrency: number; diff --git a/packages/contentstack-export/src/types/index.ts b/packages/contentstack-export/src/types/index.ts index 3f06ebdb7..d8898a89d 100644 --- a/packages/contentstack-export/src/types/index.ts +++ b/packages/contentstack-export/src/types/index.ts @@ -32,7 +32,7 @@ export interface Region { cma: string; cda: string; uiHost: string; - assetManagementUrl?: string; + csAssetsUrl?: string; } export type Modules = diff --git a/packages/contentstack-export/src/utils/constants.ts b/packages/contentstack-export/src/utils/constants.ts index 323e3ab89..bf5339f45 100644 --- a/packages/contentstack-export/src/utils/constants.ts +++ b/packages/contentstack-export/src/utils/constants.ts @@ -3,11 +3,11 @@ export const PROCESS_NAMES = { ASSET_FOLDERS: 'Folders', ASSET_METADATA: 'Metadata', ASSET_DOWNLOADS: 'Downloads', - /** Used when Assets module runs Asset Management 2.0 path (spaces, metadata, folders, assets, downloads). */ + /** Used when Assets module runs Contentstack Assets path (spaces, metadata, folders, assets, downloads). */ ASSET_MANAGEMENT_SPACES: 'Spaces & assets', - // Asset Management 2.0 module - ASSET_MANAGEMENT_EXPORT: 'Asset Management 2.0', + // Contentstack Assets module + ASSET_MANAGEMENT_EXPORT: 'Contentstack Assets', // Custom Roles module FETCH_ROLES: 'Fetch Roles', @@ -42,7 +42,7 @@ export const PROCESS_NAMES = { export const MODULE_CONTEXTS = { ASSETS: 'assets', - ASSET_MANAGEMENT: 'asset-management', + CS_ASSETS: 'cs-assets', CONTENT_TYPES: 'content-types', CUSTOM_ROLES: 'custom-roles', ENTRIES: 'entries', @@ -62,7 +62,7 @@ export const MODULE_CONTEXTS = { // Display names for modules to avoid scattering user-facing strings export const MODULE_NAMES = { [MODULE_CONTEXTS.ASSETS]: 'Assets', - [MODULE_CONTEXTS.ASSET_MANAGEMENT]: 'Asset Management 2.0', + [MODULE_CONTEXTS.CS_ASSETS]: 'Contentstack Assets', [MODULE_CONTEXTS.CONTENT_TYPES]: 'Content Types', [MODULE_CONTEXTS.CUSTOM_ROLES]: 'Custom Roles', [MODULE_CONTEXTS.ENTRIES]: 'Entries', @@ -98,10 +98,10 @@ export const PROCESS_STATUS = { EXPORTING: 'Exporting spaces & assets...', FAILED: 'Failed to export spaces & assets.', }, - // Asset Management 2.0 + // Contentstack Assets [PROCESS_NAMES.ASSET_MANAGEMENT_EXPORT]: { EXPORTING: 'Exporting...', - FAILED: 'Asset Management export failed.', + FAILED: 'Contentstack Assets export failed.', }, [PROCESS_NAMES.FETCH_ROLES]: { FETCHING: 'Fetching custom roles...', diff --git a/packages/contentstack-export/src/utils/export-config-handler.ts b/packages/contentstack-export/src/utils/export-config-handler.ts index 188444545..3b1d559ff 100644 --- a/packages/contentstack-export/src/utils/export-config-handler.ts +++ b/packages/contentstack-export/src/utils/export-config-handler.ts @@ -25,6 +25,15 @@ const setupConfig = async (exportCmdFlags: any): Promise => { log.debug('Loading external configuration file...', { configFile: exportCmdFlags['config'] }); const externalConfig = await readFile(exportCmdFlags['config']); + const legacyCsAssetsConfig = externalConfig?.modules?.['asset-management']; + if (legacyCsAssetsConfig) { + externalConfig.modules['cs-assets'] = externalConfig.modules['cs-assets'] || legacyCsAssetsConfig; + delete externalConfig.modules['asset-management']; + log.warn( + 'Config key "modules.asset-management" is deprecated. Please rename it to "modules.cs-assets".', + ); + } + config = merge.recursive(config, externalConfig); } diff --git a/packages/contentstack-export/src/utils/get-linked-workspaces.ts b/packages/contentstack-export/src/utils/get-linked-workspaces.ts index e5abca975..94e070959 100644 --- a/packages/contentstack-export/src/utils/get-linked-workspaces.ts +++ b/packages/contentstack-export/src/utils/get-linked-workspaces.ts @@ -6,7 +6,7 @@ type StackWithBranch = { branch: (name: string) => { fetch: (params?: Record, +): { total: number; success: number; failures: number } | null { + let total = 0; + let success = 0; + let failures = 0; + let found = false; + for (const [name, data] of processes) { + if (!isSpaceProcessName(name)) continue; + found = true; + total += data.total; + success += data.successCount; + failures += data.failureCount; + } + return found ? { total, success, failures } : null; +} + // Wrap all registrations in try-catch to prevent module loading errors try { ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.CONTENT_TYPES], new DefaultProgressStrategy()); @@ -31,8 +56,13 @@ try { failures: downloadsProcess.failureCount, }; } - // Asset Management 2.0 path (process name owned by AM package) - const amProcess = processes.get(AM_MAIN_PROCESS_NAME); + // Contentstack Assets (per-space layout): sum every "Space *" row so the + // final summary reports total assets-across-all-spaces. Falls through to + // the legacy AM_MAIN/SPACES rows when the per-space layout isn't in use. + const spaceTotals = aggregateSpaceProcesses(processes); + if (spaceTotals) return spaceTotals; + + const amProcess = processes.get(CS_ASSETS_MAIN_PROCESS_NAME); if (amProcess) { return { total: amProcess.total, diff --git a/packages/contentstack-export/test/unit/export/modules/assets.test.ts b/packages/contentstack-export/test/unit/export/modules/assets.test.ts index de7dd4d75..f5047a860 100644 --- a/packages/contentstack-export/test/unit/export/modules/assets.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/assets.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { FsUtility, getDirectories } from '@contentstack/cli-utilities'; -import { ExportSpaces } from '@contentstack/cli-asset-management'; +import { ExportSpaces } from '@contentstack/cli-cs-assets'; import ExportAssets from '../../../../src/export/modules/assets'; import { ExportConfig } from '../../../../src/types'; import { mockData, assetsMetaData } from '../../mock/assets'; @@ -137,7 +137,7 @@ describe('ExportAssets', () => { enableDownloadStatus: false, includeVersionedAssets: false, }, - 'asset-management': { + 'cs-assets': { chunkFileSizeMb: 1, apiConcurrency: 5, downloadAssetsConcurrency: 5, @@ -335,11 +335,11 @@ describe('ExportAssets', () => { it('should forward AM export concurrency options to ExportSpaces', async () => { mockExportConfig.linkedWorkspaces = [{ uid: 'ws-1', space_uid: 'am-space-1', is_default: true }]; - mockExportConfig.region.assetManagementUrl = 'https://am.example.com'; + mockExportConfig.region.csAssetsUrl = 'https://am.example.com'; mockExportConfig.org_uid = 'org-from-config'; - mockExportConfig.modules['asset-management'].chunkFileSizeMb = 2; - mockExportConfig.modules['asset-management'].apiConcurrency = 7; - mockExportConfig.modules['asset-management'].downloadAssetsConcurrency = 3; + mockExportConfig.modules['cs-assets'].chunkFileSizeMb = 2; + mockExportConfig.modules['cs-assets'].apiConcurrency = 7; + mockExportConfig.modules['cs-assets'].downloadAssetsConcurrency = 3; const progressManager = { addProcess: sinon.stub(), startProcess: sinon.stub(), updateStatus: sinon.stub() }; ((exportAssets as any).createNestedProgress as sinon.SinonStub).returns(progressManager as any); diff --git a/packages/contentstack-export/test/unit/export/modules/base-class.test.ts b/packages/contentstack-export/test/unit/export/modules/base-class.test.ts index 52eefd922..03c0687d0 100644 --- a/packages/contentstack-export/test/unit/export/modules/base-class.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/base-class.test.ts @@ -154,7 +154,7 @@ describe('BaseClass', () => { enableDownloadStatus: false, includeVersionedAssets: false, }, - 'asset-management': { + 'cs-assets': { chunkFileSizeMb: 1, apiConcurrency: 5, downloadAssetsConcurrency: 5, diff --git a/packages/contentstack-import-setup/README.md b/packages/contentstack-import-setup/README.md index 40ed66f01..dfb7b040c 100644 --- a/packages/contentstack-import-setup/README.md +++ b/packages/contentstack-import-setup/README.md @@ -47,7 +47,7 @@ $ npm install -g @contentstack/cli-cm-import-setup $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-import-setup/2.0.0-beta.10 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-import-setup/2.0.0-beta.6 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-import/src/config/index.ts b/packages/contentstack-import/src/config/index.ts index a15332baa..a49014296 100644 --- a/packages/contentstack-import/src/config/index.ts +++ b/packages/contentstack-import/src/config/index.ts @@ -101,7 +101,7 @@ const config: DefaultConfig = { folderValidKeys: ['name', 'parent_uid'], validKeys: ['title', 'parent_uid', 'description', 'tags'], }, - 'asset-management': { + 'cs-assets': { dirName: 'spaces', fieldsDir: 'fields', assetTypesDir: 'asset_types', diff --git a/packages/contentstack-import/src/import/modules/assets.ts b/packages/contentstack-import/src/import/modules/assets.ts index 80b3f7ef0..a24cc5a09 100644 --- a/packages/contentstack-import/src/import/modules/assets.ts +++ b/packages/contentstack-import/src/import/modules/assets.ts @@ -67,11 +67,11 @@ export default class ImportAssets extends BaseClass { try { log.debug('Starting assets import process...', this.importConfig.context); - // AM 2.0: assetManagementEnabled is set in the config handler when spaces/ + am_v2 are detected. - if (this.importConfig.assetManagementEnabled) { - if (!this.importConfig.assetManagementUrl) { + // CS Assets: csAssetsEnabled is set in the config handler when spaces/ + am_v2 are detected. + if (this.importConfig.csAssetsEnabled) { + if (!this.importConfig.csAssetsUrl) { log.info( - 'AM 2.0 export detected but assetManagementUrl is not configured in the region settings. Skipping AM 2.0 asset import.', + 'CS Assets export detected but csAssetsUrl is not configured in the region settings. Skipping CS Assets asset import.', this.importConfig.context, ); return; @@ -81,13 +81,11 @@ export default class ImportAssets extends BaseClass { let spaceMappings: SpaceMapping[] = []; try { - const importer = new ImportSpaces( - buildImportSpacesOptions(this.importConfig, this.importConfig.assetManagementUrl), - ); + const importer = new ImportSpaces(buildImportSpacesOptions(this.importConfig, this.importConfig.csAssetsUrl)); importer.setParentProgressManager(progress); ({ spaceMappings } = await importer.start()); } catch (error) { - this.completeProgress(false, (error as Error)?.message ?? 'AM 2.0 asset import failed'); + this.completeProgress(false, (error as Error)?.message ?? 'CS Assets asset import failed'); throw error; } @@ -170,10 +168,7 @@ export default class ImportAssets extends BaseClass { try { const branchUid = this.importConfig.branchName ?? 'main'; - const branchData = (await this.stack.branch(branchUid).fetch({ include_settings: true })) as Record< - string, - any - >; + const branchData = (await this.stack.branch(branchUid).fetch({ include_settings: true })) as Record; const currentLinked = (branchData?.settings?.am_v2?.linked_workspaces ?? []) as Array<{ uid: string; space_uid: string; @@ -193,14 +188,11 @@ export default class ImportAssets extends BaseClass { await this.stack.branch(branchUid).updateSettings({ branch: { settings: { am_v2: { linked_workspaces: combinedWorkspaces } } }, }); - log.success( - `Linked ${newWorkspaces.length} space(s) to branch "${branchUid}"`, - this.importConfig.context, - ); + log.success(`Linked ${newWorkspaces.length} space(s) to branch "${branchUid}"`, this.importConfig.context); } catch (linkErr) { handleAndLogError(linkErr, { ...this.importConfig.context, - phase: 'AM 2.0 branch linking (linked_workspaces)', + phase: 'CS Assets branch linking (linked_workspaces)', }); } } diff --git a/packages/contentstack-import/src/import/modules/stack.ts b/packages/contentstack-import/src/import/modules/stack.ts index 969ad4c94..96602f941 100644 --- a/packages/contentstack-import/src/import/modules/stack.ts +++ b/packages/contentstack-import/src/import/modules/stack.ts @@ -70,12 +70,12 @@ export default class ImportStack extends BaseClass { log.debug('Processing stack settings for import', this.importConfig.context); // Old source-org space UIDs must not be written to the target stack — - // the asset-management module will apply the correct am_v2.linked_workspaces. + // the cs-assets module will apply the correct am_v2.linked_workspaces. if (existsSync(join(this.importConfig.contentDir, 'spaces'))) { const { am_v2, ...settingsWithoutAm } = this.stackSettings as any; this.stackSettings = settingsWithoutAm; log.debug( - 'Stripped am_v2 from stack settings; asset-management module will apply it after space creation', + 'Stripped am_v2 from stack settings; cs-assets module will apply it after space creation', this.importConfig.context, ); } diff --git a/packages/contentstack-import/src/types/default-config.ts b/packages/contentstack-import/src/types/default-config.ts index 29e859806..3374931ce 100644 --- a/packages/contentstack-import/src/types/default-config.ts +++ b/packages/contentstack-import/src/types/default-config.ts @@ -72,7 +72,7 @@ export default interface DefaultConfig { uploadAssetsConcurrency: number; importFoldersConcurrency: number; }; - 'asset-management': { + 'cs-assets': { dirName: string; fieldsDir: string; assetTypesDir: string; diff --git a/packages/contentstack-import/src/types/import-config.ts b/packages/contentstack-import/src/types/import-config.ts index e7ee366e2..1587a360e 100644 --- a/packages/contentstack-import/src/types/import-config.ts +++ b/packages/contentstack-import/src/types/import-config.ts @@ -57,8 +57,8 @@ export default interface ImportConfig extends DefaultConfig, ExternalConfig { personalizeProjectName?: string; 'exclude-global-modules': false; context: Context; - assetManagementUrl?: string; - assetManagementEnabled?: boolean; + csAssetsUrl?: string; + csAssetsEnabled?: boolean; } type branch = { diff --git a/packages/contentstack-import/src/types/index.ts b/packages/contentstack-import/src/types/index.ts index a73584b36..d7b2c946e 100644 --- a/packages/contentstack-import/src/types/index.ts +++ b/packages/contentstack-import/src/types/index.ts @@ -19,7 +19,7 @@ export interface Region { cma: string; cda: string; uiHost: string; - assetManagementUrl?: string; + csAssetsUrl?: string; } export interface InquirePayload { diff --git a/packages/contentstack-import/src/utils/build-import-spaces-options.ts b/packages/contentstack-import/src/utils/build-import-spaces-options.ts index 32e57c8ce..f0dc54e7f 100644 --- a/packages/contentstack-import/src/utils/build-import-spaces-options.ts +++ b/packages/contentstack-import/src/utils/build-import-spaces-options.ts @@ -1,4 +1,5 @@ import type { ImportSpacesOptions } from '@contentstack/cli-asset-management'; +import { log } from '@contentstack/cli-utilities'; import { PATH_CONSTANTS } from '../constants'; import type ImportConfig from '../types/import-config'; @@ -7,16 +8,17 @@ import type ImportConfig from '../types/import-config'; * Maps stack `ImportConfig` and AM base URL into a single `ImportSpacesOptions` for the AM package * (variants-style: one flat object; `ImportSpaces` splits API vs context internally). */ -export function buildImportSpacesOptions( - importConfig: ImportConfig, - assetManagementUrl: string, -): ImportSpacesOptions { - const am = importConfig.modules['asset-management']; +export function buildImportSpacesOptions(importConfig: ImportConfig, csAssetsUrl: string): ImportSpacesOptions { + const legacyModuleConfig = (importConfig.modules as Record)['asset-management']; + const am = importConfig.modules['cs-assets'] || legacyModuleConfig; + if (!importConfig.modules['cs-assets'] && legacyModuleConfig) { + log.warn('Config key "modules.asset-management" is deprecated. Please rename it to "modules.cs-assets".'); + } const org_uid = importConfig.org_uid ?? ''; return { contentDir: importConfig.contentDir, - assetManagementUrl, + csAssetsUrl, org_uid, apiKey: importConfig.apiKey, host: importConfig.region?.cma ?? importConfig.host ?? '', diff --git a/packages/contentstack-import/src/utils/import-config-handler.ts b/packages/contentstack-import/src/utils/import-config-handler.ts index 29c00c1c8..be5807ab0 100644 --- a/packages/contentstack-import/src/utils/import-config-handler.ts +++ b/packages/contentstack-import/src/utils/import-config-handler.ts @@ -22,6 +22,15 @@ const setupConfig = async (importCmdFlags: any): Promise => { if (importCmdFlags['config']) { let externalConfig = await readFile(importCmdFlags['config']); + const legacyCsAssetsConfig = externalConfig?.modules?.['asset-management']; + if (legacyCsAssetsConfig) { + externalConfig.modules['cs-assets'] = externalConfig.modules['cs-assets'] || legacyCsAssetsConfig; + delete externalConfig.modules['asset-management']; + log.warn( + 'Config key "modules.asset-management" is deprecated. Please rename it to "modules.cs-assets".', + ); + } + if (isArray(externalConfig['modules'])) { config.modules.types = filter(config.modules.types, (module) => includes(externalConfig['modules'], module)); externalConfig = omit(externalConfig, ['modules']); @@ -133,8 +142,8 @@ const setupConfig = async (importCmdFlags: any): Promise => { try { const stackSettings = JSON.parse(readFileSync(stackSettingsPath, 'utf8')); if (stackSettings?.am_v2) { - config.assetManagementEnabled = true; - config.assetManagementUrl = configHandler.get('region')?.assetManagementUrl; + config.csAssetsEnabled = true; + config.csAssetsUrl = configHandler.get('region')?.csAssetsUrl; if (existsSync(stackJsonPath)) { try { @@ -149,7 +158,7 @@ const setupConfig = async (importCmdFlags: any): Promise => { } } } catch { - // stack settings unreadable — not an AM 2.0 export we can process + // stack settings unreadable — not an CS Assets export we can process } } diff --git a/packages/contentstack-import/src/utils/progress-strategy-registry.ts b/packages/contentstack-import/src/utils/progress-strategy-registry.ts index 5a391317d..73984ed43 100644 --- a/packages/contentstack-import/src/utils/progress-strategy-registry.ts +++ b/packages/contentstack-import/src/utils/progress-strategy-registry.ts @@ -10,8 +10,34 @@ import { CustomProgressStrategy, DefaultProgressStrategy, } from '@contentstack/cli-utilities'; +import { isSpaceProcessName } from '@contentstack/cli-asset-management'; import { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES } from './constants'; +/** + * Sum the totals/success/failure counts across every per-space process row in + * the multibar. Used by the CS Assets Assets strategy so the final import summary + * reports total assets-across-all-spaces instead of the placeholder row. + * + * Returns null when no per-space rows exist, letting the strategy fall back to + * legacy process names. + */ +function aggregateSpaceProcesses( + processes: Map, +): { total: number; success: number; failures: number } | null { + let total = 0; + let success = 0; + let failures = 0; + let found = false; + for (const [name, data] of processes) { + if (!isSpaceProcessName(name)) continue; + found = true; + total += data.total; + success += data.successCount; + failures += data.failureCount; + } + return found ? { total, success, failures } : null; +} + // Wrap all registrations in try-catch to prevent module loading errors try { // Register strategy for Content Types - use Create as primary process @@ -22,109 +48,113 @@ try { // Register strategy for Assets - use Asset Upload as primary process ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.ASSETS], - new CustomProgressStrategy((processes) => { - const uploadsProcess = processes.get(PROCESS_NAMES.ASSET_UPLOAD); - if (uploadsProcess) { - return { - total: uploadsProcess.total, - success: uploadsProcess.successCount, - failures: uploadsProcess.failureCount, - }; - } - - return null; // Fall back to default aggregation - }), -); - -// Register strategy for Entries - use Entry Creation as primary process -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.ENTRIES], - new PrimaryProcessStrategy(PROCESS_NAMES.ENTRIES_CREATE), -); - -// Register strategy for Global Fields - use Create as primary process -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.GLOBAL_FIELDS], - new PrimaryProcessStrategy(PROCESS_NAMES.GLOBAL_FIELDS_CREATE), -); - -// Register strategy for Extensions - simple module -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.EXTENSIONS], - new PrimaryProcessStrategy(PROCESS_NAMES.EXTENSIONS_CREATE), -); - -// Register strategy for Environments - uses default (no nested progress yet) -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.ENVIRONMENTS], new DefaultProgressStrategy()); - -// Register strategy for Locales - uses default (no nested progress yet) -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.LOCALES], - new PrimaryProcessStrategy(PROCESS_NAMES.LOCALES_CREATE), -); - -// Register strategy for Labels - uses default (no nested progress yet) -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.LABELS], - new PrimaryProcessStrategy(PROCESS_NAMES.LABELS_CREATE), -); - -// Register strategy for Webhooks - uses default (no nested progress yet) -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.WEBHOOKS], new DefaultProgressStrategy()); - -// Register strategy for Workflows - uses default (no nested progress yet) -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.WORKFLOWS], - new PrimaryProcessStrategy(PROCESS_NAMES.WEBHOOKS_IMPORT), -); - -// Register strategy for Custom Roles - uses default (no nested progress yet) -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.CUSTOM_ROLES], new DefaultProgressStrategy()); - -// Register strategy for Taxonomies - uses default (no nested progress yet) -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.TAXONOMIES], new DefaultProgressStrategy()); - -// Register strategy for Marketplace Apps - complex module with app installations -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.MARKETPLACE_APPS], - new PrimaryProcessStrategy(PROCESS_NAMES.CREATE_APPS), -); - -// Register strategy for Stack Settings - simple module -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.STACK], new DefaultProgressStrategy()); - -// Register strategy for Personalize - complex module with projects/experiences -ProgressStrategyRegistry.register( - MODULE_NAMES[MODULE_CONTEXTS.PERSONALIZE], - new CustomProgressStrategy((processes) => { - // For personalize import, count project imports as primary metric - const projectImport = processes.get(PROCESS_NAMES.PERSONALIZE_PROJECTS); - if (projectImport) { - return { - total: projectImport.total, - success: projectImport.successCount, - failures: projectImport.failureCount, - }; - } - - // Fallback to any other main process - const mainProcess = Array.from(processes.values())[0]; - if (mainProcess) { - return { - total: mainProcess.total, - success: mainProcess.successCount, - failures: mainProcess.failureCount, - }; - } - - return null; - }), -); - -// Register strategy for Variant Entries - sub-process of entries -ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.VARIANT_ENTRIES], new DefaultProgressStrategy()); + MODULE_NAMES[MODULE_CONTEXTS.ASSETS], + new CustomProgressStrategy((processes) => { + const uploadsProcess = processes.get(PROCESS_NAMES.ASSET_UPLOAD); + if (uploadsProcess) { + return { + total: uploadsProcess.total, + success: uploadsProcess.successCount, + failures: uploadsProcess.failureCount, + }; + } + + // Contentstack Assets (per-space layout): sum every "Space *" row so the + // final summary reports total assets-across-all-spaces. + const spaceTotals = aggregateSpaceProcesses(processes); + if (spaceTotals) return spaceTotals; + + return null; // Fall back to default aggregation + }), + ); + + // Register strategy for Entries - use Entry Creation as primary process + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.ENTRIES], + new PrimaryProcessStrategy(PROCESS_NAMES.ENTRIES_CREATE), + ); + + // Register strategy for Global Fields - use Create as primary process + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.GLOBAL_FIELDS], + new PrimaryProcessStrategy(PROCESS_NAMES.GLOBAL_FIELDS_CREATE), + ); + + // Register strategy for Extensions - simple module + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.EXTENSIONS], + new PrimaryProcessStrategy(PROCESS_NAMES.EXTENSIONS_CREATE), + ); + + // Register strategy for Environments - uses default (no nested progress yet) + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.ENVIRONMENTS], new DefaultProgressStrategy()); + + // Register strategy for Locales - uses default (no nested progress yet) + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.LOCALES], + new PrimaryProcessStrategy(PROCESS_NAMES.LOCALES_CREATE), + ); + + // Register strategy for Labels - uses default (no nested progress yet) + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.LABELS], + new PrimaryProcessStrategy(PROCESS_NAMES.LABELS_CREATE), + ); + + // Register strategy for Webhooks - uses default (no nested progress yet) + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.WEBHOOKS], new DefaultProgressStrategy()); + + // Register strategy for Workflows - uses default (no nested progress yet) + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.WORKFLOWS], + new PrimaryProcessStrategy(PROCESS_NAMES.WEBHOOKS_IMPORT), + ); + + // Register strategy for Custom Roles - uses default (no nested progress yet) + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.CUSTOM_ROLES], new DefaultProgressStrategy()); + + // Register strategy for Taxonomies - uses default (no nested progress yet) + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.TAXONOMIES], new DefaultProgressStrategy()); + + // Register strategy for Marketplace Apps - complex module with app installations + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.MARKETPLACE_APPS], + new PrimaryProcessStrategy(PROCESS_NAMES.CREATE_APPS), + ); + + // Register strategy for Stack Settings - simple module + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.STACK], new DefaultProgressStrategy()); + + // Register strategy for Personalize - complex module with projects/experiences + ProgressStrategyRegistry.register( + MODULE_NAMES[MODULE_CONTEXTS.PERSONALIZE], + new CustomProgressStrategy((processes) => { + // For personalize import, count project imports as primary metric + const projectImport = processes.get(PROCESS_NAMES.PERSONALIZE_PROJECTS); + if (projectImport) { + return { + total: projectImport.total, + success: projectImport.successCount, + failures: projectImport.failureCount, + }; + } + + // Fallback to any other main process + const mainProcess = Array.from(processes.values())[0]; + if (mainProcess) { + return { + total: mainProcess.total, + success: mainProcess.successCount, + failures: mainProcess.failureCount, + }; + } + + return null; + }), + ); + // Register strategy for Variant Entries - sub-process of entries + ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.VARIANT_ENTRIES], new DefaultProgressStrategy()); } catch (error) { // Silently ignore registration errors during module loading } diff --git a/packages/contentstack-import/test/unit/import/modules/locales.test.ts b/packages/contentstack-import/test/unit/import/modules/locales.test.ts index 5bc5e8f7f..0bdfd8e6d 100644 --- a/packages/contentstack-import/test/unit/import/modules/locales.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/locales.test.ts @@ -62,7 +62,7 @@ describe('ImportLocales', () => { folderValidKeys: ['uid', 'name'], validKeys: ['uid', 'title'], }, - 'asset-management': { + 'cs-assets': { dirName: 'spaces', fieldsDir: 'fields', assetTypesDir: 'asset_types', diff --git a/packages/contentstack-import/test/unit/utils/extension-helper.test.ts b/packages/contentstack-import/test/unit/utils/extension-helper.test.ts index f99a8fd9a..522b1e3da 100644 --- a/packages/contentstack-import/test/unit/utils/extension-helper.test.ts +++ b/packages/contentstack-import/test/unit/utils/extension-helper.test.ts @@ -67,7 +67,7 @@ describe('Extension Helper', () => { folderValidKeys: ['uid', 'name'], validKeys: ['uid', 'title'], }, - 'asset-management': { + 'cs-assets': { dirName: 'spaces', fieldsDir: 'fields', assetTypesDir: 'asset_types', diff --git a/skills/README.md b/skills/README.md deleted file mode 100644 index 3257d9d50..000000000 --- a/skills/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Skills – Contentstack CLI plugins - -Source of truth for detailed guidance. Read [AGENTS.md](../AGENTS.md) for the skill index, then open the `SKILL.md` that matches your task. Each folder contains `SKILL.md` with YAML frontmatter (`name`, `description`).