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/src/constants/index.ts b/packages/contentstack-asset-management/src/constants/index.ts index 9d6bca636..f7f0ff0c8 100644 --- a/packages/contentstack-asset-management/src/constants/index.ts +++ b/packages/contentstack-asset-management/src/constants/index.ts @@ -35,7 +35,17 @@ export const CHUNK_FILE_SIZE_MB = FALLBACK_AM_CHUNK_FILE_SIZE_MB; export const AM_MAIN_PROCESS_NAME = 'Asset Management 2.0'; /** - * Process names for Asset Management 2.0 export progress (for tick labels). + * Process names for Asset Management 2.0 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 +61,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 AM 2.0 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..bd6c5f17c 100644 --- a/packages/contentstack-asset-management/src/export/asset-types.ts +++ b/packages/contentstack-asset-management/src/export/asset-types.ts @@ -7,6 +7,8 @@ import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; export default class ExportAssetTypes extends AssetManagementExportAdapter { + protected processName: string = PROCESS_NAMES.AM_ASSET_TYPES; + constructor(apiConfig: AssetManagementAPIConfig, 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..dc8587111 100644 --- a/packages/contentstack-asset-management/src/export/assets.ts +++ b/packages/contentstack-asset-management/src/export/assets.ts @@ -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..856781653 100644 --- a/packages/contentstack-asset-management/src/export/base.ts +++ b/packages/contentstack-asset-management/src/export/base.ts @@ -18,7 +18,7 @@ export class AssetManagementExportAdapter extends AssetManagementAdapter { 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 = AM_MAIN_PROCESS_NAME; constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { super(apiConfig); @@ -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..08e1caa6e 100644 --- a/packages/contentstack-asset-management/src/export/fields.ts +++ b/packages/contentstack-asset-management/src/export/fields.ts @@ -7,6 +7,8 @@ import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; export default class ExportFields extends AssetManagementExportAdapter { + protected processName: string = PROCESS_NAMES.AM_FIELDS; + constructor(apiConfig: AssetManagementAPIConfig, 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/spaces.ts b/packages/contentstack-asset-management/src/export/spaces.ts index 24a5a088d..1147cdb1a 100644 --- a/packages/contentstack-asset-management/src/export/spaces.ts +++ b/packages/contentstack-asset-management/src/export/spaces.ts @@ -4,8 +4,7 @@ import { log, CLIProgressManager, configHandler, handleAndLogError } from '@cont import type { AssetManagementExportOptions, AssetManagementAPIConfig } from '../types/asset-management-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 { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; import ExportAssetTypes from './asset-types'; import ExportFields from './fields'; import ExportWorkspace from './workspaces'; @@ -55,12 +54,18 @@ export class ExportSpaces { 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, @@ -82,39 +87,67 @@ 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.info( + anySpaceFailed + ? 'Asset Management export completed with errors in one or more spaces' + : 'Asset Management export completed successfully', + context, + ); log.debug('Asset Management 2.0 export completed', context); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); + 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) }, 'Asset Management export failed'); throw err; } diff --git a/packages/contentstack-asset-management/src/export/workspaces.ts b/packages/contentstack-asset-management/src/export/workspaces.ts index c2f5bb4f1..0d45196e1 100644 --- a/packages/contentstack-asset-management/src/export/workspaces.ts +++ b/packages/contentstack-asset-management/src/export/workspaces.ts @@ -6,16 +6,33 @@ import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-m import type { ExportContext } from '../types/export-types'; import { AssetManagementExportAdapter } from './base'; import ExportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; export default class ExportWorkspace extends AssetManagementExportAdapter { constructor(apiConfig: AssetManagementAPIConfig, 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..0e943edee 100644 --- a/packages/contentstack-asset-management/src/import/asset-types.ts +++ b/packages/contentstack-asset-management/src/import/asset-types.ts @@ -24,6 +24,11 @@ type AssetTypeToCreate = { uid: string; payload: Record }; * 5. Strip read-only/computed keys from the POST body before creating new asset types. */ export default class ImportAssetTypes extends AssetManagementImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_ASSET_TYPES; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + constructor(apiConfig: AssetManagementAPIConfig, 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..d665353b2 100644 --- a/packages/contentstack-asset-management/src/import/assets.ts +++ b/packages/contentstack-asset-management/src/import/assets.ts @@ -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..24fca0918 100644 --- a/packages/contentstack-asset-management/src/import/base.ts +++ b/packages/contentstack-asset-management/src/import/base.ts @@ -16,7 +16,7 @@ export class AssetManagementImportAdapter extends AssetManagementAdapter { 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 = AM_MAIN_PROCESS_NAME; constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { super(apiConfig); @@ -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; } diff --git a/packages/contentstack-asset-management/src/import/fields.ts b/packages/contentstack-asset-management/src/import/fields.ts index 9785906c2..2bba913f6 100644 --- a/packages/contentstack-asset-management/src/import/fields.ts +++ b/packages/contentstack-asset-management/src/import/fields.ts @@ -24,6 +24,11 @@ type FieldToCreate = { uid: string; payload: Record }; * 5. Strip read-only/computed keys from the POST body before creating new fields. */ export default class ImportFields extends AssetManagementImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_FIELDS; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + constructor(apiConfig: AssetManagementAPIConfig, 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/spaces.ts b/packages/contentstack-asset-management/src/import/spaces.ts index 6f66d24be..13706ffad 100644 --- a/packages/contentstack-asset-management/src/import/spaces.ts +++ b/packages/contentstack-asset-management/src/import/spaces.ts @@ -10,7 +10,7 @@ import type { ImportSpacesOptions, SpaceMapping, } from '../types/asset-management-api'; -import { AM_MAIN_PROCESS_NAME } from '../constants/index'; +import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; import ImportAssetTypes from './asset-types'; import ImportFields from './fields'; @@ -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 = {}; @@ -117,27 +125,40 @@ export class ImportSpaces { log.info('Started Asset Management 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); } } @@ -181,14 +198,20 @@ export class ImportSpaces { log.debug('Wrote AM 2.0 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.`, context, ); - log.debug('Asset Management 2.0 import completed', context); + log.debug( + `Asset Management 2.0 import completed (hasFailures=${hasFailures})`, + context, + ); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); + // 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) }, 'Asset Management import failed'); throw err; } diff --git a/packages/contentstack-asset-management/src/import/workspaces.ts b/packages/contentstack-asset-management/src/import/workspaces.ts index e042b1f3b..d685cc3d2 100644 --- a/packages/contentstack-asset-management/src/import/workspaces.ts +++ b/packages/contentstack-asset-management/src/import/workspaces.ts @@ -5,7 +5,6 @@ import { log } from '@contentstack/cli-utilities'; import type { AssetManagementAPIConfig, ImportContext, SpaceMapping } from '../types/asset-management-api'; import { AssetManagementImportAdapter } from './base'; import ImportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; type WorkspaceResult = SpaceMapping & { uidMap: Record; @@ -23,13 +22,26 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { 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,6 +60,9 @@ 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)) { @@ -57,7 +72,9 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { ); 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/test/unit/export/asset-types.test.ts b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts index af052e2db..e0fd3bb1b 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 @@ -3,7 +3,6 @@ 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 type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; import type { ExportContext } from '../../../src/types/export-types'; @@ -76,13 +75,19 @@ describe('ExportAssetTypes', () => { 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]); + 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..2c4ac124e 100644 --- a/packages/contentstack-asset-management/test/unit/export/assets.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/assets.test.ts @@ -106,11 +106,11 @@ describe('ExportAssets', () => { 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 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')); @@ -119,12 +119,16 @@ describe('ExportAssets', () => { 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 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); @@ -133,9 +137,13 @@ describe('ExportAssets', () => { 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 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 () => { @@ -168,9 +176,10 @@ describe('ExportAssets', () => { 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 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 () => { @@ -191,8 +200,9 @@ describe('ExportAssets', () => { 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 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 () => { @@ -238,9 +248,10 @@ describe('ExportAssets', () => { 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 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 () => { @@ -254,9 +265,10 @@ describe('ExportAssets', () => { 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 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/fields.test.ts b/packages/contentstack-asset-management/test/unit/export/fields.test.ts index a039dcb75..008ceebe3 100644 --- a/packages/contentstack-asset-management/test/unit/export/fields.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/fields.test.ts @@ -3,7 +3,6 @@ import sinon from 'sinon'; import ExportFields from '../../../src/export/fields'; import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { PROCESS_NAMES } from '../../../src/constants/index'; import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; import type { ExportContext } from '../../../src/types/export-types'; @@ -76,13 +75,19 @@ describe('ExportFields', () => { 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]); + 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..3228ab8c1 100644 --- a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts @@ -7,7 +7,7 @@ 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 { PROCESS_NAMES, getSpaceProcessName } from '../../../src/constants/index'; import type { AssetManagementExportOptions, LinkedWorkspace } from '../../../src/types/asset-management-api'; @@ -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..03bdfc6bf 100644 --- a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts @@ -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); + // 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 = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, `space: ${workspace.space_uid}`, null]); + 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-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..39c357b96 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-asset-management`. + */ +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-branches/README.md b/packages/contentstack-branches/README.md index c22682f9b..5fcb24e51 100755 --- a/packages/contentstack-branches/README.md +++ b/packages/contentstack-branches/README.md @@ -53,7 +53,6 @@ USAGE * [`csdx cm:branches:delete [-uid ] [-k ]`](#csdx-cmbranchesdelete--uid-value--k-value) * [`csdx cm:branches:diff [--base-branch ] [--compare-branch ] [-k ][--module ] [--format ] [--csv-path ]`](#csdx-cmbranchesdiff---base-branch-value---compare-branch-value--k-value--module-value---format-value---csv-path-value) * [`csdx cm:branches:merge [-k ][--compare-branch ] [--no-revert] [--export-summary-path ] [--use-merge-summary ] [--comment ] [--base-branch ]`](#csdx-cmbranchesmerge--k-value--compare-branch-value---no-revert---export-summary-path-value---use-merge-summary-value---comment-value---base-branch-value) -* [`csdx cm:branches:merge-status -k --merge-uid `](#csdx-cmbranchesmerge-status--k-value---merge-uid-value) ## `csdx cm:branches` @@ -231,27 +230,4 @@ EXAMPLES ``` _See code: [src/commands/cm/branches/merge.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge.ts)_ - -## `csdx cm:branches:merge-status -k --merge-uid ` - -Check the status of a branch merge job - -``` -USAGE - $ csdx cm:branches:merge-status -k --merge-uid - -FLAGS - -k, --stack-api-key= (required) Provide your stack API key. - --merge-uid= (required) Merge job UID to check status for. - -DESCRIPTION - Check the status of a branch merge job - -EXAMPLES - $ csdx cm:branches:merge-status -k bltxxxxxxxx --merge-uid merge_abc123 - - $ csdx cm:branches:merge-status --stack-api-key bltxxxxxxxx --merge-uid merge_abc123 -``` - -_See code: [src/commands/cm/branches/merge-status.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge-status.ts)_ diff --git a/packages/contentstack-clone/README.md b/packages/contentstack-clone/README.md index 4c818981a..f67e967e7 100644 --- a/packages/contentstack-clone/README.md +++ b/packages/contentstack-clone/README.md @@ -16,7 +16,7 @@ $ npm install -g @contentstack/cli-cm-clone $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-clone/2.0.0-beta.17 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-clone/2.0.0-beta.12 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-export/src/utils/progress-strategy-registry.ts b/packages/contentstack-export/src/utils/progress-strategy-registry.ts index b50c1e86b..85a2fdfe2 100644 --- a/packages/contentstack-export/src/utils/progress-strategy-registry.ts +++ b/packages/contentstack-export/src/utils/progress-strategy-registry.ts @@ -1,4 +1,4 @@ -import { AM_MAIN_PROCESS_NAME } from '@contentstack/cli-asset-management'; +import { AM_MAIN_PROCESS_NAME, isSpaceProcessName } from '@contentstack/cli-asset-management'; import { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES } from './constants'; /** * Progress Strategy Registrations for Export Modules @@ -13,6 +13,31 @@ import { DefaultProgressStrategy, } from '@contentstack/cli-utilities'; +/** + * Sum the totals/success/failure counts across every per-space process row in + * the multibar. Used by the AM 2.0 Assets strategy so the final 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 { ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.CONTENT_TYPES], new DefaultProgressStrategy()); @@ -31,7 +56,12 @@ try { failures: downloadsProcess.failureCount, }; } - // Asset Management 2.0 path (process name owned by AM package) + // Asset Management 2.0 (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(AM_MAIN_PROCESS_NAME); if (amProcess) { return { 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/utils/progress-strategy-registry.ts b/packages/contentstack-import/src/utils/progress-strategy-registry.ts index 5a391317d..0d5137346 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 AM 2.0 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 @@ -33,6 +59,11 @@ try { }; } + // Asset Management 2.0 (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 }), ); diff --git a/skills/README.md b/skills/README.md index 3257d9d50..5b71abe19 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,3 +1 @@ -# 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`). +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).