diff --git a/.talismanrc b/.talismanrc index 21b122b9a..5954e5dfd 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,18 +1,13 @@ fileignoreconfig: + - filename: pnpm-lock.yaml - checksum: 693eb73aae594f391247f2feecc55b84447cd39658b6f70f25ef87df19b5c134 -- filename: packages/contentstack-export/src/utils/export-config-handler.ts - checksum: 3ff8e8ea60f92311f8224bce457f7aaab026194de61e13eebdc6ca5a39f66bc9 -- filename: packages/contentstack-asset-management/src/utils/export-helpers.ts - checksum: 726d1632110ebc203ce70dc1cbe7d4b3011f56349ed371b6645d0375e7818cad -- filename: packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts - checksum: bc4a53f96be6a10786e00133245c7bdc43c965c8a98b753e3879e1110cf9c601 -- filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts - checksum: 63c6bff4d51842d8fa3cce88545259d0a2c3cfe71df95d303d993f692cee883b -- filename: packages/contentstack-migration/src/commands/cm/stacks/migration.ts - checksum: 529431473623cf6bbefb35bc898be2a60dcb453e0c9273ecb97923de658769b3 -- filename: packages/contentstack-seed/src/commands/cm/stacks/seed.ts - checksum: 9a3fd45a57c6ebf63d6c154657f2ebf799befbbacb9a255944c2d89ae89b22ed -- filename: packages/contentstack-seed/test/commands/cm/stacks/seed.test.ts - checksum: b54f572a614e5d80f7fa27a51e679147cb537fd76ac922eed981d1156a261e50 -version: '1.0' + checksum: 649aae748e3ae8c157439dc6c7c9ea402cc19b4dab7ca64b7e4d839d1375df53 +- filename: packages/contentstack-audit/src/audit-base-command.ts + checksum: b706ea114a3cb5e4c0e7e163f32ceffa3356a6b0766bf5b8fe387c3c72f44c36 +- filename: packages/contentstack-audit/src/types/content-types.ts + checksum: d16a65415c3184f15a807d58e5858310aeb5633794fc9075e36f89c94da636c3 +- filename: packages/contentstack-audit/src/modules/entries.ts + checksum: 13072df0872f8c098d23f49b02f9e8f3e492ab2f483d67a5ea5e423ab406ad94 +- filename: packages/contentstack-audit/test/unit/modules/entries.test.ts + checksum: 31d9767d87e4e9db54f641e8cb9d3360f591acdaa8b089a83eaa4aacdf0c3a35 + version: '1.0' diff --git a/packages/contentstack-apps-cli/package.json b/packages/contentstack-apps-cli/package.json index da7c90599..fe10e0933 100644 --- a/packages/contentstack-apps-cli/package.json +++ b/packages/contentstack-apps-cli/package.json @@ -25,8 +25,8 @@ "@contentstack/cli-command": "~2.0.0-beta.11", "@contentstack/cli-launch": "^1.11.1", "@contentstack/cli-utilities": "~2.0.0-beta.12", - "adm-zip": "^0.5.18", "chalk": "^5.6.2", + "adm-zip": "^0.6.0", "lodash": "^4.18.1", "shelljs": "^0.10.0", "tmp": "^0.2.7", @@ -110,4 +110,4 @@ "app:deploy": "APDP" } } -} +} \ No newline at end of file diff --git a/packages/contentstack-audit/README.md b/packages/contentstack-audit/README.md index 70bc988ad..eb2abe898 100644 --- a/packages/contentstack-audit/README.md +++ b/packages/contentstack-audit/README.md @@ -19,7 +19,7 @@ $ npm install -g @contentstack/cli-audit $ csdx COMMAND running command... $ csdx (--version|-v) -@contentstack/cli-audit/2.0.0-beta.15 darwin-arm64 node-v24.14.0 +@contentstack/cli-audit/1.21.0 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-audit/package.json b/packages/contentstack-audit/package.json index a6a96cde2..d5a8f1189 100644 --- a/packages/contentstack-audit/package.json +++ b/packages/contentstack-audit/package.json @@ -80,4 +80,4 @@ "keywords": [ "oclif" ] -} +} \ No newline at end of file diff --git a/packages/contentstack-audit/src/audit-base-command.ts b/packages/contentstack-audit/src/audit-base-command.ts index d154636f3..e099280c7 100644 --- a/packages/contentstack-audit/src/audit-base-command.ts +++ b/packages/contentstack-audit/src/audit-base-command.ts @@ -15,7 +15,7 @@ import { clearProgressModuleSetting, readContentTypeSchemas, readGlobalFieldSchemas, - generateUid + generateUid, } from '@contentstack/cli-utilities'; import { createWriteStream, existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import config from './config'; @@ -56,7 +56,9 @@ export abstract class AuditBaseCommand extends BaseCommand { - return row.fixStatus === 'Fixed' ? getChalk().greenBright(row.fixStatus) : getChalk().redBright(row.fixStatus); + return row.fixStatus === 'Fixed' + ? getChalk().greenBright(row.fixStatus) + : getChalk().redBright(row.fixStatus); }, }, }; @@ -104,6 +106,8 @@ export abstract class AuditBaseCommand extends BaseCommand; missingEnvLocale?: Record; missingMultipleFields?: Record; + missingAssetRefs?: Record; } = {}, missingMandatoryFields, missingTitleFields, @@ -231,6 +246,8 @@ export abstract class AuditBaseCommand extends BaseCommand = {}; if (data.gfSchema?.length) { - gfFieldRules = await new FieldRule( - cloneDeep({ ...constructorParam, moduleName: 'global-fields' }), - ).run(data.gfSchema.length); + gfFieldRules = await new FieldRule(cloneDeep({ ...constructorParam, moduleName: 'global-fields' })).run( + data.gfSchema.length, + ); } missingFieldRules = { ...ctFieldRules, ...gfFieldRules }; @@ -455,6 +479,8 @@ export abstract class AuditBaseCommand extends BaseCommand, ): Promise { log.debug(`Preparing report for module: ${moduleName}`, this.auditContext); @@ -682,7 +709,8 @@ export abstract class AuditBaseCommand extends BaseCommand, ): Promise { if (Object.keys(config.moduleConfig).includes(moduleName) || config.field_level_modules.includes(moduleName)) { diff --git a/packages/contentstack-audit/src/config/index.ts b/packages/contentstack-audit/src/config/index.ts index 808f46c1b..7a56afa16 100644 --- a/packages/contentstack-audit/src/config/index.ts +++ b/packages/contentstack-audit/src/config/index.ts @@ -55,6 +55,9 @@ const config = { name: 'assets', dirName: 'assets', fileName: 'assets.json', + // Asset scan statuses that must block import/reference; any other value (including + // 'not_scanned', 'clean', or the field being absent) is treated as safe. + blockingScanStatuses: ['pending', 'quarantined'], }, environments: { name: 'environments', @@ -111,6 +114,8 @@ const config = { 'publish_environment', 'asset_uid', 'space_id', + 'scan_status', + 'mandatory', 'selectedValue', 'ct_uid', 'action', @@ -130,6 +135,7 @@ const config = { Entry_Missing_Locale_and_Env: 'Entry_Missing_Locale_and_Env', Entry_Missing_Locale_and_Env_in_Publish_Details: 'Entry_Missing_Locale_and_Env_in_Publish_Details', Entry_Multiple_Fields: 'Entry_Multiple_Fields', + Entries_Asset_field: 'Entries_Asset_field', }, field_level_modules: [ 'Entries_Title_field', @@ -138,6 +144,8 @@ const config = { 'Entry_Missing_Locale_and_Env_in_Publish_Details', 'field-rules', 'Entry_Multiple_Fields', + 'Entries_Asset_field', + 'asset-scan-status', 'Summary', ], fixSelectField: false, diff --git a/packages/contentstack-audit/src/messages/index.ts b/packages/contentstack-audit/src/messages/index.ts index 7a130702e..d5bf41cb5 100644 --- a/packages/contentstack-audit/src/messages/index.ts +++ b/packages/contentstack-audit/src/messages/index.ts @@ -53,6 +53,8 @@ const auditMsg = { FIELD_RULE_TARGET_ABSENT: `The target field '{target_field}' is not present in the schema of the content-type {ctUid}`, FIELD_RULE_CONDITION_SCAN_MESSAGE: `Completed Scanning of Field Rule '{num}' condition of Content-type '{ctUid}'`, FIELD_RULE_TARGET_SCAN_MESSAGE: `Completed Scanning of Field Rule '{num}' target of Content-type '{ctUid}'`, + SCAN_ASSET_QUARANTINE_MSG: `Asset with UID '{uid}' has a non-clean scan status ('{status}') and will be excluded from import.`, + ENTRY_ASSET_REF_WARN_MSG: `Entry '{uid}' field '{field}' references asset '{asset_uid}' with scan status '{status}'; it will be removed on fix.`, }; const auditFixMsg = { @@ -66,7 +68,9 @@ const auditFixMsg = { ENTRY_MANDATORY_FIELD_FIX: `Removing the publish details from the entry with UID '{uid}' in Locale '{locale}'...`, ENTRY_SELECT_FIELD_FIX: `Adding the value '{value}' in the select field of entry UID '{uid}'...`, ASSET_FIX: `Fixed publish details for Asset with UID '{uid}'`, - FIELD_RULE_FIX_MESSAGE: `Fixed Field Rule '{num}' target of Content-type '{ctUid}'`, + FIELD_RULE_FIX_MESSAGE: `Fixed Field Rule '{num}' target of Content-type '{ctUid}`, + ASSET_SCAN_STATUS_FIX: `Removed asset with UID '{uid}' (scan status: '{status}') from assets.json.`, + ENTRY_ASSET_REF_FIX: `Removed reference to asset '{asset_uid}' from entry '{uid}' field '{field}'.`, }; const messages: typeof errors & diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index e4c7c6499..6d574fa4b 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -35,6 +35,7 @@ export default class Assets extends BaseClass { public environments: string[] = []; protected schema: ContentTypeStruct[] = []; protected missingEnvLocales: Record = {}; + public missingScanStatusAssets: Record = {}; public moduleName: keyof typeof auditConfig.moduleConfig; private fixOverwriteConfirmed: boolean | null = null; /** API key of the exported stack; null when `stack/stack.json` is missing or unreadable. */ @@ -342,6 +343,30 @@ export default class Assets extends BaseClass { for (const assetUid in assets) { log.debug(`Processing asset: ${assetUid}`, this.config.auditContext); + const scanStatus = this.assets[assetUid]?._asset_scan_status; + if (this.config.moduleConfig.assets.blockingScanStatuses.includes(scanStatus)) { + log.debug(`Asset ${assetUid} has a non-clean scan status: ${scanStatus}`, this.config.auditContext); + cliux.print($t(auditMsg.SCAN_ASSET_QUARANTINE_MSG, { uid: assetUid, status: scanStatus }), { + color: 'yellow', + }); + this.missingScanStatusAssets[assetUid] = [ + { asset_uid: assetUid, filename: this.assets[assetUid].filename, scan_status: scanStatus, space_id: spaceId }, + ]; + + if (this.fix) { + log.info( + $t(auditFixMsg.ASSET_SCAN_STATUS_FIX, { uid: assetUid, status: scanStatus }), + this.config.auditContext, + ); + delete this.assets[assetUid]; + chunkChanged = true; + if (this.progressManager) { + this.progressManager.tick(true, `asset: ${assetUid}`, null, spaceProcessName); + } + continue; + } + } + if (this.assets[assetUid]?.publish_details && !Array.isArray(this.assets[assetUid].publish_details)) { log.debug(`Asset ${assetUid} has invalid publish_details format`, this.config.auditContext); cliux.print($t(auditMsg.ASSET_NOT_EXIST, { uid: assetUid }), { color: 'red' }); diff --git a/packages/contentstack-audit/src/modules/entries.ts b/packages/contentstack-audit/src/modules/entries.ts index 5e17a9f83..c85dfa6cf 100644 --- a/packages/contentstack-audit/src/modules/entries.ts +++ b/packages/contentstack-audit/src/modules/entries.ts @@ -57,8 +57,11 @@ export default class Entries extends BaseClass { protected missingTitleFields: Record = {}; protected missingEnvLocale: Record = {}; protected missingMultipleField: Record = {}; + protected missingAssetRefs: Record = {}; public environments: string[] = []; public entryMetaData: Record[] = []; + public assetMetaData: Record = {}; + public assetsDataAvailable = false; public moduleName: keyof typeof auditConfig.moduleConfig = 'entries'; private fixOverwriteConfirmed: boolean | null = null; @@ -156,6 +159,11 @@ export default class Entries extends BaseClass { }); log.debug(`Entry metadata prepared: ${this.entryMetaData.length} entries found`, this.config.auditContext); + log.debug('Preparing asset metadata', this.config.auditContext); + await this.prepareAssetMetaData(); + log.debug(`Asset metadata prepared: ${Object.keys(this.assetMetaData).length} assets found`, this.config.auditContext); + + // Fix prerequisite data with loading spinner await this.withLoadingSpinner('ENTRIES: Fixing prerequisite data...', async () => { await this.fixPrerequisiteData(); @@ -211,6 +219,10 @@ export default class Entries extends BaseClass { if (!this.missingMandatoryFields[this.currentUid]) { this.missingMandatoryFields[this.currentUid] = []; } + + if (!this.missingAssetRefs[this.currentUid]) { + this.missingAssetRefs[this.currentUid] = []; + } if (this.fix) { log.debug(`Removing missing keys from entry ${uid}`, this.config.auditContext); this.removeMissingKeysOnEntry(ctSchema.schema as ContentTypeSchemaType[], this.entries[entryUid]); @@ -256,6 +268,17 @@ export default class Entries extends BaseClass { }); } + if (this.missingAssetRefs[this.currentUid]?.length) { + log.debug( + `Found ${this.missingAssetRefs[this.currentUid].length} missing/quarantined asset references for entry ${uid}`, + this.config.auditContext, + ); + this.missingAssetRefs[this.currentUid].forEach((entry: any) => { + entry.ct = ctSchema.uid; + entry.locale = code; + }); + } + const fields = this.missingMandatoryFields[uid]; const isPublished = entry.publish_details?.length > 0; log.debug( @@ -396,6 +419,7 @@ export default class Entries extends BaseClass { missingTitleFields: this.missingTitleFields, missingEnvLocale: this.missingEnvLocale, missingMultipleFields: this.missingMultipleField, + missingAssetRefs: this.missingAssetRefs, }; log.debug(`Entries audit completed. Found issues:`, this.config.auditContext); @@ -406,6 +430,7 @@ export default class Entries extends BaseClass { this.config.auditContext, ); log.debug(`- Missing title fields: ${Object.keys(this.missingTitleFields).length}`, this.config.auditContext); + log.debug(`- Missing/quarantined asset references: ${Object.keys(this.missingAssetRefs).length}`, this.config.auditContext); log.debug(`- Missing environment/locale: ${Object.keys(this.missingEnvLocale).length}`, this.config.auditContext); log.debug( `- Missing multiple fields: ${Object.keys(this.missingMultipleField).length}`, @@ -453,8 +478,17 @@ export default class Entries extends BaseClass { } } + let removedAssetRefs = 0; + for (let propName in this.missingAssetRefs) { + if (!this.missingAssetRefs[propName].length) { + log.debug(`Removing empty missing asset references for entry: ${propName}`, this.config.auditContext); + delete this.missingAssetRefs[propName]; + removedAssetRefs++; + } + } + log.debug( - `Cleanup completed: removed ${removedRefs} empty refs, ${removedSelectFields} empty select fields, ${removedMandatoryFields} empty mandatory fields`, + `Cleanup completed: removed ${removedRefs} empty refs, ${removedSelectFields} empty select fields, ${removedMandatoryFields} empty mandatory fields, ${removedAssetRefs} empty asset refs`, this.config.auditContext, ); } @@ -710,6 +744,16 @@ export default class Entries extends BaseClass { entry[uid] as EntryGroupFieldDataType[], ); break; + case 'file': + log.debug(`Validating file/asset field: ${display_name}`, this.config.auditContext); + const assetRefResults = this.validateFileField( + [...tree, { uid: child.uid, name: child.display_name, field: uid }], + child, + entry[uid], + ); + this.missingAssetRefs[this.currentUid].push(...assetRefResults); + log.debug(`Found ${assetRefResults.length} quarantined/missing asset references in field: ${display_name}`, this.config.auditContext); + break; case 'text': case 'number': if (child.hasOwnProperty('display_type')) { @@ -986,6 +1030,60 @@ export default class Entries extends BaseClass { log.debug(`Group field validation completed for: ${fieldStructure.display_name}`); } + /** + * Returns true when the given asset uid should be treated as unusable — either it doesn't + * exist in the exported assets.json at all, or it exists but its scan status is one of + * config.moduleConfig.assets.blockingScanStatuses (e.g. 'pending'/'quarantined'). Any other + * status — including 'clean', 'not_scanned' (org has asset scanning disabled), or the field + * being absent — is not a blocking condition. Returns false (never flag) when asset metadata + * wasn't available at all, since we can't validate what we don't have data for. + */ + isAssetBad(uid?: string): boolean { + if (!this.assetsDataAvailable || !uid) return false; + const assetRecord = this.assetMetaData[uid]; + if (!assetRecord) return true; + return this.config.moduleConfig.assets.blockingScanStatuses.includes(assetRecord._asset_scan_status ?? ''); + } + + /** + * The function `validateFileField` checks a `data_type: 'file'` (asset reference) field's + * value(s) against the asset metadata index and returns an issue when any referenced asset is + * missing or has a non-clean scan status. + */ + validateFileField( + tree: Record[], + fieldStructure: { uid: string; data_type: string; display_name: string; mandatory?: boolean }, + field: any, + ): EntryRefErrorReturnType[] { + log.debug(`Validating file/asset field: ${fieldStructure.display_name}`, this.config.auditContext); + + const values = Array.isArray(field) ? field : field ? [field] : []; + const missingRefs = values + .filter((ref: any) => this.isAssetBad(ref?.uid)) + .map((ref: any) => ({ asset_uid: ref?.uid, filename: ref?.filename })); + + if (isEmpty(missingRefs)) { + log.debug('File/asset field validation completed: no issues found', this.config.auditContext); + return []; + } + + return [ + { + tree, + missingRefs, + uid: this.currentUid, + name: this.currentTitle, + data_type: fieldStructure.data_type, + display_name: fieldStructure.display_name, + mandatory: fieldStructure.mandatory, + treeStr: tree + .map(({ name }) => name) + .filter((val) => val) + .join(' ➜ '), + } as unknown as EntryRefErrorReturnType, + ]; + } + /** * The function `validateReferenceValues` checks if the references in a given field exist in the * provided tree and returns any missing references. @@ -1232,6 +1330,14 @@ export default class Entries extends BaseClass { entry[uid] as EntryGroupFieldDataType[], ) as EntryGroupFieldDataType; break; + case 'file': + log.debug(`Fixing file/asset field: ${uid}`); + this.fixFileFieldReferences( + [...tree, { uid: field.uid, name: field.display_name, data_type: field.data_type }], + field, + entry, + ); + break; case 'text': case 'number': if (field.hasOwnProperty('display_type')) { @@ -1705,6 +1811,65 @@ export default class Entries extends BaseClass { return field; } + /** + * The function `fixFileFieldReferences` strips references to missing/quarantined/pending-scan + * assets from a `data_type: 'file'` field. A single-object value with a bad asset uid is deleted + * from the entry entirely; a `multiple: true` array value has its bad entries filtered out, and + * the key itself is deleted if the array becomes empty as a result. + */ + fixFileFieldReferences( + tree: Record[], + field: { uid: string; data_type: string; display_name: string; mandatory?: boolean }, + entry: Record, + ) { + log.debug(`Fixing file/asset field: ${field.display_name}`); + const { uid, display_name, data_type, mandatory } = field; + const value = entry[uid]; + + if (value == null) { + return entry; + } + + const missingRefs: Record[] = []; + + if (Array.isArray(value)) { + entry[uid] = value.filter((ref: any) => { + if (this.isAssetBad(ref?.uid)) { + missingRefs.push({ asset_uid: ref?.uid, filename: ref?.filename }); + return false; + } + return true; + }); + if (!entry[uid].length) { + delete entry[uid]; + } + } else if (this.isAssetBad(value?.uid)) { + missingRefs.push({ asset_uid: value?.uid, filename: value?.filename }); + delete entry[uid]; + } + + if (!isEmpty(missingRefs)) { + log.debug(`Recording asset reference fix for entry: ${this.currentUid}`); + this.missingAssetRefs[this.currentUid].push({ + tree, + data_type, + missingRefs, + display_name, + mandatory, + fixStatus: 'Fixed', + uid: this.currentUid, + name: this.currentTitle, + treeStr: tree + .map(({ name }) => name) + .filter((val) => val) + .join(' ➜ '), + }); + } + + log.debug(`File/asset fix completed for: ${field.display_name}`); + return entry; + } + /** * The function `fixGroupField` takes in a tree, a field, and an entry, and if the field has a * schema, it runs a fix on the schema and returns the updated entry, otherwise it returns the @@ -2140,4 +2305,45 @@ export default class Entries extends BaseClass { ); log.debug(`Missing title fields found: ${Object.keys(this.missingTitleFields).length}`, this.config.auditContext); } + + /** + * Builds an index of asset uid -> { uid, filename, _asset_scan_status } from the exported + * assets/assets.json, mirroring the FsUtility chunk-read pattern used in the Assets module + * (src/modules/assets.ts). If the assets data isn't present in this export at all, + * `assetsDataAvailable` stays false and `isAssetBad()` never flags anything — absence of data + * must not be treated as every file-field reference being broken. + */ + async prepareAssetMetaData() { + log.debug('Starting asset metadata preparation', this.config.auditContext); + + const assetsBasePath = resolve( + sanitizePath(this.config.basePath), + sanitizePath(this.config.moduleConfig.assets.dirName), + ); + const assetsIndexPath = join(assetsBasePath, this.config.moduleConfig.assets.fileName); + + if (!existsSync(assetsIndexPath)) { + log.debug(`No assets data found at: ${assetsIndexPath}`, this.config.auditContext); + this.assetsDataAvailable = false; + return; + } + + this.assetsDataAvailable = true; + const fsUtility = new FsUtility({ basePath: assetsBasePath, indexFileName: 'assets.json' }); + const indexer = fsUtility.indexFileContent; + log.debug(`Found ${Object.keys(indexer).length} asset files to process`, this.config.auditContext); + + for (const _ in indexer) { + const assets = (await fsUtility.readChunkFiles.next()) as Record; + for (const assetUid in assets) { + this.assetMetaData[assetUid] = { + uid: assetUid, + filename: assets[assetUid]?.filename, + _asset_scan_status: assets[assetUid]?._asset_scan_status, + }; + } + } + + log.debug(`Asset metadata preparation completed: ${Object.keys(this.assetMetaData).length} assets processed`, this.config.auditContext); + } } diff --git a/packages/contentstack-audit/src/types/content-types.ts b/packages/contentstack-audit/src/types/content-types.ts index 59cd78316..d5b210244 100644 --- a/packages/contentstack-audit/src/types/content-types.ts +++ b/packages/contentstack-audit/src/types/content-types.ts @@ -163,20 +163,22 @@ enum OutputColumn { 'publish_environment' = 'publish_environment', 'asset_uid' = 'asset_uid', 'space_id' = 'space_id', + 'scan_status' = 'scan_status', + 'mandatory' = 'mandatory', 'selectedValue' = 'selectedValue', 'fixStatus' = 'fixStatus', 'Content_type_uid' = 'ct_uid', 'action' = 'action', 'field_uid' = 'field_uid', 'multiple' = 'multiple', - Module="Module", - "Total"="Total", - "Passed"="Passed", - "Fixable"="Fixable", - "Non-Fixable"="Non-Fixable", - "Fixed" = "Fixed", - "Not-Fixed" = "Not-Fixed", - "Issues" = "issues", + Module = 'Module', + 'Total' = 'Total', + 'Passed' = 'Passed', + 'Fixable' = 'Fixable', + 'Non-Fixable' = 'Non-Fixable', + 'Fixed' = 'Fixed', + 'Not-Fixed' = 'Not-Fixed', + 'Issues' = 'issues', } export { @@ -202,13 +204,17 @@ export { type FieldRuleStruct = { match_type: string; - actions: Array<{ - action: string; - target_field: string; - }> | undefined; - conditions: Array<{ - value: string; - operand_field: string; - operator: string; - }> | undefined; + actions: + | Array<{ + action: string; + target_field: string; + }> + | undefined; + conditions: + | Array<{ + value: string; + operand_field: string; + operator: string; + }> + | undefined; }; diff --git a/packages/contentstack-audit/test/unit/mock/contents/assets/assets.json b/packages/contentstack-audit/test/unit/mock/contents/assets/assets.json index f86475d27..6a8bec655 100644 --- a/packages/contentstack-audit/test/unit/mock/contents/assets/assets.json +++ b/packages/contentstack-audit/test/unit/mock/contents/assets/assets.json @@ -1 +1 @@ -{"1": "chunk0-assets.json"} +{"1": "chunk0-assets.json", "2": "chunk1-assets.json"} \ No newline at end of file diff --git a/packages/contentstack-audit/test/unit/mock/contents/assets/chunk1-assets.json b/packages/contentstack-audit/test/unit/mock/contents/assets/chunk1-assets.json new file mode 100644 index 000000000..e3ddd99ba --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/contents/assets/chunk1-assets.json @@ -0,0 +1,41 @@ +{ + "blt-clean-asset": { + "uid": "blt-clean-asset", + "filename": "clean.jpg", + "url": "https://images.contentstack.io/v3/assets/blt/blt-clean-asset/clean.jpg", + "_version": 1, + "_asset_scan_status": "clean", + "publish_details": [] + }, + "blt-pending-asset": { + "uid": "blt-pending-asset", + "filename": "pending.zip", + "url": "https://assets.contentstack.io/v3/assets/blt/blt-pending-asset/pending.zip", + "_version": 1, + "_asset_scan_status": "pending", + "publish_details": [] + }, + "blt-quarantined-asset": { + "uid": "blt-quarantined-asset", + "filename": "quarantined.zip", + "url": "https://assets.contentstack.io/v3/assets/blt/blt-quarantined-asset/quarantined.zip", + "_version": 1, + "_asset_scan_status": "quarantined", + "publish_details": [] + }, + "blt-no-status-asset": { + "uid": "blt-no-status-asset", + "filename": "no-status.png", + "url": "https://images.contentstack.io/v3/assets/blt/blt-no-status-asset/no-status.png", + "_version": 1, + "publish_details": [] + }, + "blt-not-scanned-asset": { + "uid": "blt-not-scanned-asset", + "filename": "not-scanned.png", + "url": "https://images.contentstack.io/v3/assets/blt/blt-not-scanned-asset/not-scanned.png", + "_version": 1, + "_asset_scan_status": "not_scanned", + "publish_details": [] + } +} diff --git a/packages/contentstack-audit/test/unit/modules/assets.test.ts b/packages/contentstack-audit/test/unit/modules/assets.test.ts index 857621483..f686ef5db 100644 --- a/packages/contentstack-audit/test/unit/modules/assets.test.ts +++ b/packages/contentstack-audit/test/unit/modules/assets.test.ts @@ -10,7 +10,6 @@ import { $t, auditMsg } from '../../../src/messages'; import Assets from '../../../src/modules/assets'; import { ModuleConstructorParam, CtConstructorParam } from '../../../src/types'; import { mockLogger } from '../mock-logger'; - const mockContentsPath = resolve(__dirname, '..', 'mock', 'contents'); describe('Assets module', () => { @@ -351,7 +350,9 @@ describe('Assets module', () => { await instance.prerequisiteData(); const writeFixSpy = Sinon.stub(Assets.prototype, 'writeFixContent').resolves(); await instance.lookForReference(); - expect(writeFixSpy.callCount).to.equal(1); + // Two chunk files change (chunk0: invalid publish_details, chunk1: blocked scan-status + // assets) — the assertion is that this is per-chunk, not per-asset (which would be 8). + expect(writeFixSpy.callCount).to.equal(2); }); fancy @@ -369,7 +370,16 @@ describe('Assets module', () => { (call: Sinon.SinonSpyCall) => typeof call.args[0] === 'string' && call.args[0].includes("Successfully completed the scanning of Asset with UID"), ); - const expectedAssetUids = ['asset_uid_1', 'asset_uid_invalid', 'asset_uid_two_invalid']; + const expectedAssetUids = [ + 'asset_uid_1', + 'asset_uid_invalid', + 'asset_uid_two_invalid', + 'blt-clean-asset', + 'blt-pending-asset', + 'blt-quarantined-asset', + 'blt-no-status-asset', + 'blt-not-scanned-asset', + ]; expect(successMsgCalls).to.have.lengthOf(expectedAssetUids.length); expectedAssetUids.forEach((uid) => { const forUid = successMsgCalls.filter((c: Sinon.SinonSpyCall) => c.args[0].includes(uid)); @@ -605,4 +615,54 @@ describe('Assets module', () => { expect((result as any).asset_uid_two_invalid).to.have.lengthOf(2); }); }); + + describe('lookForReference method (scan status)', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('flags assets with a non-clean scan status and leaves clean/no-status assets alone', async () => { + const assetsInstance = new Assets(constructorParam); + await assetsInstance.prerequisiteData(); + await assetsInstance.lookForReference(); + + expect(Object.keys(assetsInstance.missingScanStatusAssets)).to.have.members([ + 'blt-pending-asset', + 'blt-quarantined-asset', + ]); + expect(assetsInstance.missingScanStatusAssets['blt-pending-asset'][0]).to.deep.include({ + asset_uid: 'blt-pending-asset', + scan_status: 'pending', + }); + expect(assetsInstance.missingScanStatusAssets['blt-quarantined-asset'][0]).to.deep.include({ + asset_uid: 'blt-quarantined-asset', + scan_status: 'quarantined', + }); + expect(assetsInstance.missingScanStatusAssets).to.not.have.property('blt-clean-asset'); + expect(assetsInstance.missingScanStatusAssets).to.not.have.property('blt-no-status-asset'); + expect(assetsInstance.missingScanStatusAssets).to.not.have.property('blt-not-scanned-asset'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(fs, 'writeFileSync', () => {}) + .it('removes non-clean assets from the written-back chunk when fix mode is on', async () => { + const writeFileSyncStub = Sinon.spy(fs, 'writeFileSync'); + const assetsInstance = new Assets({ + ...constructorParam, + fix: true, + config: { ...constructorParam.config, flags: { yes: true } }, + }); + await assetsInstance.prerequisiteData(); + await assetsInstance.lookForReference(); + + expect(writeFileSyncStub.called).to.be.true; + const chunk1Call = writeFileSyncStub.getCalls().find((c) => String(c.args[0]).includes('chunk1')); + expect(chunk1Call).to.not.be.undefined; + const writtenContent = JSON.parse(chunk1Call!.args[1] as string); + expect(writtenContent).to.not.have.property('blt-pending-asset'); + expect(writtenContent).to.not.have.property('blt-quarantined-asset'); + expect(writtenContent).to.have.property('blt-clean-asset'); + expect(writtenContent).to.have.property('blt-no-status-asset'); + expect(writtenContent).to.have.property('blt-not-scanned-asset'); + }); + }); }); diff --git a/packages/contentstack-audit/test/unit/modules/entries.test.ts b/packages/contentstack-audit/test/unit/modules/entries.test.ts index c10f8c824..19a3f80a2 100644 --- a/packages/contentstack-audit/test/unit/modules/entries.test.ts +++ b/packages/contentstack-audit/test/unit/modules/entries.test.ts @@ -2287,4 +2287,254 @@ describe('Entries module', () => { (Entries.prototype.jsonRefCheck as any).restore(); }); }); + + describe('prepareAssetMetaData method', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('builds an asset metadata index from assets/assets.json when it exists', async () => { + const ctInstance = new Entries(constructorParam); + await ctInstance.prepareAssetMetaData(); + + expect(ctInstance.assetsDataAvailable).to.be.true; + expect(ctInstance.assetMetaData['blt-clean-asset']).to.deep.include({ + uid: 'blt-clean-asset', + _asset_scan_status: 'clean', + }); + expect(ctInstance.assetMetaData['blt-pending-asset']).to.deep.include({ + uid: 'blt-pending-asset', + _asset_scan_status: 'pending', + }); + expect(ctInstance.assetMetaData['blt-not-scanned-asset']).to.deep.include({ + uid: 'blt-not-scanned-asset', + _asset_scan_status: 'not_scanned', + }); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('leaves assetsDataAvailable false when no assets data exists in this export', async () => { + const ctInstance = new Entries({ + ...constructorParam, + config: { ...constructorParam.config, basePath: resolve(__dirname, '..', 'mock', 'contents-1') }, + }); + await ctInstance.prepareAssetMetaData(); + + expect(ctInstance.assetsDataAvailable).to.be.false; + expect(ctInstance.assetMetaData).to.deep.equal({}); + }); + }); + + describe('isAssetBad method', () => { + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns false when assets data is unavailable', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = false; + (ctInstance as any).assetMetaData = {}; + expect(ctInstance.isAssetBad('blt-unknown')).to.be.false; + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns false when uid is falsy', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = true; + expect(ctInstance.isAssetBad(undefined)).to.be.false; + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns true when the asset uid is not found in the index', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = {}; + expect(ctInstance.isAssetBad('blt-unknown')).to.be.true; + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns true when the asset scan status is non-clean', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { 'blt-1': { uid: 'blt-1', _asset_scan_status: 'quarantined' } }; + expect(ctInstance.isAssetBad('blt-1')).to.be.true; + }); + + fancy.stdout({ print: process.env.PRINT === 'true' || false }).it('returns false when the asset is clean or has no scan status', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { + 'blt-clean': { uid: 'blt-clean', _asset_scan_status: 'clean' }, + 'blt-legacy': { uid: 'blt-legacy' }, + }; + expect(ctInstance.isAssetBad('blt-clean')).to.be.false; + expect(ctInstance.isAssetBad('blt-legacy')).to.be.false; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('returns false when the asset scan status is not_scanned (org has asset scanning disabled)', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { + 'blt-not-scanned': { uid: 'blt-not-scanned', _asset_scan_status: 'not_scanned' }, + }; + expect(ctInstance.isAssetBad('blt-not-scanned')).to.be.false; + }); + }); + + describe('validateFileField method', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('flags a single file-field value that references a quarantined asset', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).currentTitle = 'Test Entry'; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { + 'blt-quarantined': { uid: 'blt-quarantined', _asset_scan_status: 'quarantined' }, + }; + + const fieldStructure = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const value = { uid: 'blt-quarantined', filename: 'bad.zip' }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.validateFileField(tree, fieldStructure, value); + + expect(result).to.have.length(1); + expect(result[0].missingRefs).to.deep.equal([{ asset_uid: 'blt-quarantined', filename: 'bad.zip' }]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not flag a single file-field value that references a clean asset', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { 'blt-clean': { uid: 'blt-clean', _asset_scan_status: 'clean' } }; + + const fieldStructure = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const value = { uid: 'blt-clean', filename: 'ok.jpg' }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.validateFileField(tree, fieldStructure, value); + + expect(result).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('flags only the bad entries within a multiple:true file-field array', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { 'blt-clean': { uid: 'blt-clean', _asset_scan_status: 'clean' } }; + + const fieldStructure = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const value = [ + { uid: 'blt-clean', filename: 'ok.jpg' }, + { uid: 'blt-pending', filename: 'bad.zip' }, + ]; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.validateFileField(tree, fieldStructure, value); + + expect(result).to.have.length(1); + expect(result[0].missingRefs).to.deep.equal([{ asset_uid: 'blt-pending', filename: 'bad.zip' }]); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('does not flag anything when asset metadata is unavailable', () => { + const ctInstance = new Entries(constructorParam); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).assetsDataAvailable = false; + + const fieldStructure = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const value = { uid: 'blt-unknown', filename: 'unknown.jpg' }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.validateFileField(tree, fieldStructure, value); + + expect(result).to.have.length(0); + }); + }); + + describe('fixFileFieldReferences method', () => { + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('deletes the field key when a single file-field value references a bad asset', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).currentTitle = 'Test Entry'; + (ctInstance as any).missingAssetRefs = { 'test-entry': [] }; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { + 'blt-quarantined': { uid: 'blt-quarantined', _asset_scan_status: 'quarantined' }, + }; + + const field = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const entry: Record = { file_field: { uid: 'blt-quarantined', filename: 'bad.zip' } }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.fixFileFieldReferences(tree, field, entry); + + expect(result).to.not.have.property('file_field'); + expect((ctInstance as any).missingAssetRefs['test-entry']).to.have.length(1); + expect((ctInstance as any).missingAssetRefs['test-entry'][0].fixStatus).to.equal('Fixed'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('leaves a single file-field value referencing a clean asset untouched', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingAssetRefs = { 'test-entry': [] }; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = { 'blt-clean': { uid: 'blt-clean', _asset_scan_status: 'clean' } }; + + const field = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const entry: Record = { file_field: { uid: 'blt-clean', filename: 'ok.jpg' } }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + ctInstance.fixFileFieldReferences(tree, field, entry); + + expect(entry).to.have.property('file_field'); + expect((ctInstance as any).missingAssetRefs['test-entry']).to.have.length(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('filters bad entries out of a multiple:true array and deletes the key if it empties out', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingAssetRefs = { 'test-entry': [] }; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = {}; + + const field = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: false }; + const entry: Record = { + file_field: [ + { uid: 'blt-1', filename: 'one.zip' }, + { uid: 'blt-2', filename: 'two.zip' }, + ], + }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + const result = ctInstance.fixFileFieldReferences(tree, field, entry); + + expect(result).to.not.have.property('file_field'); + expect((ctInstance as any).missingAssetRefs['test-entry'][0].missingRefs).to.have.length(2); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('records mandatory:true on the fix issue so a stripped required field stays visible in the report', () => { + const ctInstance = new Entries({ ...constructorParam, fix: true }); + (ctInstance as any).currentUid = 'test-entry'; + (ctInstance as any).missingAssetRefs = { 'test-entry': [] }; + (ctInstance as any).assetsDataAvailable = true; + (ctInstance as any).assetMetaData = {}; + + const field = { uid: 'file_field', display_name: 'File Field', data_type: 'file', mandatory: true }; + const entry: Record = { file_field: { uid: 'blt-missing', filename: 'gone.zip' } }; + const tree = [{ uid: 'test-entry', name: 'Test Entry' }]; + + ctInstance.fixFileFieldReferences(tree, field, entry); + + expect((ctInstance as any).missingAssetRefs['test-entry'][0].mandatory).to.be.true; + }); + }); }); diff --git a/packages/contentstack-bootstrap/README.md b/packages/contentstack-bootstrap/README.md index bb81bbe20..e9cb57263 100644 --- a/packages/contentstack-bootstrap/README.md +++ b/packages/contentstack-bootstrap/README.md @@ -15,7 +15,7 @@ $ npm install -g @contentstack/cli-cm-bootstrap $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-bootstrap/2.0.0-beta.24 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-bootstrap/1.19.9 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-branches/README.md b/packages/contentstack-branches/README.md index f203ffbfa..92632558c 100755 --- a/packages/contentstack-branches/README.md +++ b/packages/contentstack-branches/README.md @@ -19,7 +19,7 @@ $ npm install -g @contentstack/cli-cm-branches $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-branches/2.0.0-beta.10 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-branches/1.8.5 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-branches/package.json b/packages/contentstack-branches/package.json index c08170c7a..ad0b94a12 100644 --- a/packages/contentstack-branches/package.json +++ b/packages/contentstack-branches/package.json @@ -75,4 +75,4 @@ "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-branches" } -} +} \ No newline at end of file diff --git a/packages/contentstack-bulk-operations/package.json b/packages/contentstack-bulk-operations/package.json index 26747d445..8b706bfe7 100644 --- a/packages/contentstack-bulk-operations/package.json +++ b/packages/contentstack-bulk-operations/package.json @@ -23,8 +23,8 @@ "@contentstack/cli-asset-management": "1.0.0-beta.8", "@contentstack/cli-command": "~2.0.0-beta.11", "@contentstack/cli-utilities": "~2.0.0-beta.12", - "@contentstack/delivery-sdk": "^5.2.0", - "@contentstack/management": "^1.30.2", + "@contentstack/delivery-sdk": "^5.4.0", + "@contentstack/management": "^1.30.3", "lodash": "^4.18.1", "uuid": "^14.0.1" }, diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index 700217563..3251e19ab 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -144,7 +144,7 @@ export abstract class BaseBulkCommand extends Command { protected rateLimiter!: AdaptiveRateLimiter; protected retryStrategy!: RetryStrategy; protected operationExecutor!: OperationExecutor; - private batchResults: Map = new Map(); + protected batchResults: Map = new Map(); protected parsedFlags: any; /** @@ -192,7 +192,7 @@ export abstract class BaseBulkCommand extends Command { } // Fill missing required flags via interactive prompts - flags = await fillMissingFlags(flags); + flags = await this.resolveFlagsInteractively(flags); this.parsedFlags = flags; await this.buildConfiguration(flags); @@ -295,6 +295,13 @@ export abstract class BaseBulkCommand extends Command { } } + /** + * Resolve flags interactively — subclasses can override to skip prompts for specific modes. + */ + protected async resolveFlagsInteractively(flags: any): Promise { + return await fillMissingFlags(flags); + } + /** * Build operation configuration */ diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts index 1706fefba..868f324c2 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts @@ -1,11 +1,17 @@ -import { flags, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; +import * as fs from 'fs'; +import * as path from 'path'; -import { ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces'; +import { flags, handleAndLogError, log, FlagInput } from '@contentstack/cli-utilities'; + +import { AssetPublishData, BulkOperationResult, ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces'; import { BaseBulkCommand } from '../../../base-bulk-command'; import { $t, messages, fetchAssets, + scanDataDirStats, + BATCH_CONSTANTS, + categorizeByScanStatus, fillMissingCsAssetsFlags, promptForOperation, runCsAssetsOperation, @@ -14,6 +20,8 @@ import { OperationFlagMatrixError, RETRY_REVERT_CONTEXT, } from '../../../utils'; +import type { DataDirScanStats } from '../../../utils'; +import { AssetService } from '../../../services'; type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string }; @@ -26,8 +34,8 @@ const ALL_OPERATION_CHOICES = [ /** * Bulk operations command for assets - * Supports publish, unpublish, and cross publish operations (CMS), plus - * delete and move operations (CS Assets). + * Supports publish, unpublish, cross publish, and data-dir publish operations (CMS), + * plus delete and move operations (CS Assets). * * The two families use fully separate execution paths: * - publish/unpublish run through the BaseBulkCommand pipeline (stack setup, @@ -60,6 +68,9 @@ export default class BulkAssets extends BaseBulkCommand { // Revert (unpublish) previously published assets using success log '<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias', + // Publish assets from exported content folder (e.g. after asset scanning clears) + '<%= config.bin %> <%= command.id %> --data-dir ./content --operation publish -k blt123', + // CS Assets bulk delete (asset UIDs from a JSON file `{ "uids": [...] }`) '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltOrg --locale en-us --asset-uids-file ./assets.json', @@ -77,6 +88,14 @@ export default class BulkAssets extends BaseBulkCommand { 'folder-uid': flags.string({ description: messages.FOLDER_UID, }), + 'data-dir': flags.string({ + char: 'd', + description: messages.DATA_DIR_FLAG_DESC, + }), + 'dry-run': flags.boolean({ + description: messages.DRY_RUN_FLAG_DESC, + default: false, + }), // CS Assets delete/move flags 'space-uid': flags.string({ @@ -172,12 +191,16 @@ export default class BulkAssets extends BaseBulkCommand { } try { - // Handle cross-publish separately if source-env is specified if (this.bulkOperationConfig.sourceEnv) { await this.handleCrossPublish(this.parsedFlags); return; } + if (this.bulkOperationConfig.dataDir) { + await this.runDataDirFlow(); + return; + } + const assets = await this.fetchItems(); if (assets.length === 0) { @@ -185,18 +208,39 @@ export default class BulkAssets extends BaseBulkCommand { return; } - this.logger.info( - $t(messages.FOUND_ASSETS_TO_OPERATE, { count: assets.length, operation: this.parsedFlags.operation || '' }) - ); + const { clean, pending, quarantined, noStatus } = categorizeByScanStatus(assets); + const scanningEnabled = clean.length + pending.length + quarantined.length > 0; + const publishable = scanningEnabled ? clean : [...clean, ...noStatus]; + + if (scanningEnabled) { + // Log individual skipped assets + pending.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: a.uid }))); + quarantined.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: a.uid }))); + + this.printScanningDashboard({ + total: assets.length, + clean: clean.length, + pending: pending.length, + quarantined: quarantined.length, + }); + + if (publishable.length === 0) { + this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS)); + return; + } + } else { + log.info( + $t(messages.FOUND_ASSETS_TO_OPERATE, { count: assets.length, operation: this.parsedFlags.operation || '' }) + ); + } - // Confirm operation - const confirmed = await this.confirmOperation(assets); + const confirmed = await this.confirmOperation(publishable); if (!confirmed) { this.logger.warn($t(messages.OPERATION_CANCELLED)); return; } - const result = await this.executeBulkOperation(assets); + const result = await this.executeBulkOperation(publishable); this.printOperationSummary(result); } catch (error) { handleAndLogError(error); @@ -205,6 +249,218 @@ export default class BulkAssets extends BaseBulkCommand { } } + private async runDataDirFlow(): Promise { + const { dataDir, dryRun } = this.bulkOperationConfig; + + // Capture original CLI locales/envs before pass 1 overwrites them on the config. + const cliLocales = [...(this.bulkOperationConfig.locales || [])]; + const cliEnvs = [...(this.bulkOperationConfig.environments || [])]; + + // Pass 1 — count-only scan: no AssetPublishData objects built, one chunk in memory at a time. + let stats: DataDirScanStats; + try { + stats = await scanDataDirStats(dataDir!, cliEnvs, cliLocales, this.logger); + } catch (err: any) { + this.logger.error($t(messages.DATA_DIR_READ_ERROR, { path: dataDir!, error: err.message || String(err) })); + return; + } + + this.bulkOperationConfig.environments = stats.environments; + this.bulkOperationConfig.locales = stats.locales; + + // Pass 1.5 — fetch scan status for all target UIDs (post-import UIDs on the destination stack). + const targetUids = Object.values(stats.assetUidMapper); + const assetService = new AssetService(this.managementStack, this.deliveryStack, this.logger); + const scanStatusMap = await assetService.fetchScanStatusByUIDs(targetUids); + + let cleanCount = 0; + let pendingCount = 0; + let quarantinedCount = 0; + for (const uid of targetUids) { + const status = scanStatusMap.get(uid); + if (status === 'pending') pendingCount++; + else if (status === 'quarantined') quarantinedCount++; + else cleanCount++; // clean or undefined (scanning disabled) — both are publishable + } + + this.printScanningDashboard({ + total: stats.eligible + stats.skipped + stats.unmapped, + localSkipped: stats.skipped, + unmapped: stats.unmapped, + clean: cleanCount, + pending: pendingCount, + quarantined: quarantinedCount, + }); + + if (cleanCount === 0) { + this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS)); + return; + } + + // new Array(n) has .length === n but allocates no elements — just for the count. + const confirmed = await this.confirmOperation(new Array(cleanCount)); + if (!confirmed) { + this.logger.warn($t(messages.OPERATION_CANCELLED)); + return; + } + + if (dryRun) { + log.info($t(messages.DATA_DIR_DRY_RUN)); + return; + } + + // Pass 2 — stream and publish: one chunk at a time, batches of ≤50 items enqueued directly. + // stats.assetUidMapper and stats.assetsIndex are reused from pass 1 — no second disk read. + const result = await this.streamAndPublish( + dataDir!, + cliLocales, + stats.totalItems, + stats.assetUidMapper, + stats.assetsIndex, + scanStatusMap + ); + this.printOperationSummary(result); + } + + /** + * Pass 2 of the data-dir flow. + * Reads chunk files one at a time, fills a working batch of ≤50 AssetPublishData items, + * and enqueues each batch directly into the queue manager without ever holding the full + * asset list in memory. Peak memory: one chunk file + one batch of ≤50 items. + * + * assetUidMapper and assetsIndex are passed in from pass 1 to avoid re-reading those files. + * scanStatusMap filters out non-clean assets before enqueueing. + */ + private async streamAndPublish( + dataDir: string, + cliLocales: string[], + totalItemCount: number, + assetUidMapper: Record, + assetsIndex: Record, + scanStatusMap: Map + ): Promise { + // Snapshot both arrays so in-flight mutations to bulkOperationConfig can't corrupt payloads. + const environments = [...this.bulkOperationConfig.environments!]; + const locales = [...this.bulkOperationConfig.locales!]; + const operation = this.bulkOperationConfig.operation as OperationType; + const startTime = Date.now(); + + // Warn early if the mapper is empty — all assets will be skipped and the user needs to know why. + if (Object.keys(assetUidMapper).length === 0) { + this.logger.warn( + 'Asset UID mapper is empty — all assets will be skipped. Ensure the import completed successfully.' + ); + } + + const useOverrideLocales = cliLocales.length > 0; + const BATCH_SIZE = BATCH_CONSTANTS.maxItems; + // totalItemCount comes from pass 1 using identical counting logic — used as upper bound for totalBatches. + // Scan status filtering may reduce the actual count; the invariant check below will log any mismatch. + const totalBatches = Math.ceil(totalItemCount / BATCH_SIZE); + + let workingBatch: AssetPublishData[] = []; + let batchNumber = 0; + let totalSubmitted = 0; + + this.batchResults.clear(); + + const flushBatch = (): void => { + if (workingBatch.length === 0) return; + batchNumber++; + this.queueManager.enqueue(ResourceType.ASSET, operation, { + items: [...workingBatch], + environments, + locales, + batchNumber, + totalBatches, + operation, + }); + totalSubmitted += workingBatch.length; + workingBatch = []; + }; + + for (const chunkFilename of Object.values(assetsIndex)) { + const chunkPath = path.join(dataDir, 'assets', chunkFilename); + const chunkData: Record = JSON.parse(fs.readFileSync(chunkPath, 'utf-8')); + + for (const asset of Object.values(chunkData)) { + if (!asset.publish_details || asset.publish_details.length === 0) continue; + const targetUid = assetUidMapper[asset.uid as string]; + if (!targetUid) continue; + + // Skip assets that did not pass scanning. + const scanStatus = scanStatusMap.get(targetUid); + if (scanStatus === 'quarantined') { + this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: targetUid })); + continue; + } + if (scanStatus === 'pending') { + this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: targetUid })); + continue; + } + + const assetLocales: string[] = useOverrideLocales + ? cliLocales + : [...new Set(asset.publish_details.map((pd: any) => pd.locale as string))]; + + for (const locale of assetLocales) { + workingBatch.push({ type: 'asset', uid: targetUid, locale, version: asset._version }); + if (workingBatch.length >= BATCH_SIZE) { + flushBatch(); + } + } + } + // chunkData falls out of scope here — GC can reclaim it before the next chunk is read. + } + + flushBatch(); + + // Invariant: pass 1 and pass 2 use identical counting logic (excluding scan status filtering). + // If batchNumber < totalBatches, scan status filtering reduced the published count — expected. + if (batchNumber !== totalBatches) { + this.logger.debug( + `Batch count: predicted ${totalBatches}, actual ${batchNumber}. Difference is expected when assets are skipped due to scan status.` + ); + } + + await this.queueManager.waitForCompletion(); + + const duration = Date.now() - startTime; + const jobIds = [...this.batchResults.values()].map((r) => r.jobId).filter((id): id is string => !!id); + + return { success: 0, failed: 0, total: totalSubmitted, duration, jobIds }; + } + + private printScanningDashboard(opts: { + total: number; + clean: number; + pending: number; + quarantined: number; + localSkipped?: number; + unmapped?: number; + }): void { + const { total, clean, pending, quarantined, localSkipped, unmapped } = opts; + const SEP = '─'.repeat(42); + + log.info(''); + log.info(` ${messages.DATA_DIR_ASSET_SCANNING_HEADER}`); + log.info(' ' + SEP); + log.info(` ${messages.DATA_DIR_TOTAL.padEnd(38)} ${total}`); + if (localSkipped !== undefined) { + log.warn(` ${messages.DATA_DIR_NO_PUBLISH_DETAILS.padEnd(38)} ${localSkipped}`); + } + if (unmapped !== undefined) { + log.warn(` ${messages.DATA_DIR_UNMAPPED.padEnd(38)} ${unmapped}`); + } + log.info(' ' + SEP); + log.info(` ${messages.SCAN_STATUS_CLEAN.padEnd(38)} ${clean}`); + if (pending > 0) log.warn(` ${messages.SCAN_STATUS_PENDING.padEnd(38)} ${pending}`); + if (quarantined > 0) log.warn(` ${messages.SCAN_STATUS_QUARANTINED.padEnd(38)} ${quarantined}`); + log.info(' ' + SEP); + log.info(` ${messages.DATA_DIR_WILL_PUBLISH.padEnd(38)} ${clean}`); + log.info(''); + } + protected async fetchItems(): Promise { return await fetchAssets(this.bulkOperationConfig, this.managementStack, this.deliveryStack, this.logger); } diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index 5871fcaff..1474f3fa8 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -59,6 +59,8 @@ export interface BulkOperationConfig { // Asset-specific options folderUid?: string; + dataDir?: string; + dryRun?: boolean; // Cross-publish sourceEnv?: string; @@ -135,6 +137,7 @@ export interface Asset { title?: string; _version?: number; publish_details?: PublishDetails[]; + _asset_scan_status?: 'pending' | 'clean' | 'quarantined'; [key: string]: any; } @@ -196,6 +199,8 @@ export interface CommandFlags { // Asset-specific flags 'folder-uid'?: string; + 'data-dir'?: string; + 'dry-run'?: boolean; /** CS Assets bulk delete/move */ 'space-uid'?: string; @@ -253,6 +258,7 @@ export interface AssetPublishData { locale: string; version?: number; publish_details?: PublishDetails[]; + _asset_scan_status?: 'pending' | 'clean' | 'quarantined'; } /** One row for CS Assets bulk-delete payload `{ uid, locale }[]`. */ diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index 7cb4ceeb4..00b72a124 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -212,6 +212,25 @@ const bulkAssetsMsg = { CROSS_PUBLISHING: 'Cross-publishing from {sourceEnv} to {targetEnvs}', SYNCED_ASSETS: 'Synced {count} assets from {sourceEnv}', ASSETS_READY_FOR_CROSS_PUBLISH: '{count} assets ready for cross-publish', + + // Data-dir / scanning dashboard + DATA_DIR_ASSET_SCANNING_HEADER: 'Asset Scan Status', + DATA_DIR_TOTAL: 'Total assets found', + DATA_DIR_VALID: 'Clean (will publish)', + DATA_DIR_NO_PUBLISH_DETAILS: 'No publish details (skipped)', + DATA_DIR_UNMAPPED: 'Not imported / UID unmapped (skipped)', + DATA_DIR_WILL_PUBLISH: 'Will publish', + DATA_DIR_DRY_RUN: 'Dry run — no publish API calls will be made.', + DATA_DIR_FLAG_DESC: 'Path to exported content folder containing asset publish details.', + DRY_RUN_FLAG_DESC: 'Preview the publish plan without making any API calls.', + DATA_DIR_READ_ERROR: 'Failed to read data directory at {path}: {error}', + SCAN_STATUS_CLEAN: 'Clean (will publish)', + SCAN_STATUS_PENDING: 'Still scanning (skipped)', + SCAN_STATUS_QUARANTINED: 'Quarantined (skipped)', + SCAN_STATUS_SKIPPED_PENDING: 'Skipped (still scanning): {uid}', + SCAN_STATUS_SKIPPED_QUARANTINED: 'Skipped (quarantined): {uid}', + SCAN_STATUS_FETCHING: 'Checking asset scan status for {count} assets...', + NO_PUBLISHABLE_ASSETS: 'No publishable assets — all assets are either still scanning or quarantined.', }; /** diff --git a/packages/contentstack-bulk-operations/src/services/asset-service.ts b/packages/contentstack-bulk-operations/src/services/asset-service.ts index aba844fb1..562e316af 100644 --- a/packages/contentstack-bulk-operations/src/services/asset-service.ts +++ b/packages/contentstack-bulk-operations/src/services/asset-service.ts @@ -37,7 +37,7 @@ export class AssetService { const batchUids = uids.slice(i, i + BATCH_CONSTANTS.assetFetchBatchSize); const batchPromises = batchUids.map(async (uid) => { try { - const asset = this.deliveryStack ? await this.deliveryStack.asset(uid).fetch() : undefined; + const asset = await this.deliveryStack?.asset(uid).fetch(); return asset; } catch (error: any) { // Asset might not exist or not be published to this environment @@ -125,7 +125,13 @@ export class AssetService { try { while (hasMore) { - const queryOptions: any = { skip, limit, include_count: true, include_publish_details: true }; + const queryOptions: any = { + skip, + limit, + include_count: true, + include_publish_details: true, + include_asset_scan_status: true, + }; // Add any filters from options if (options.query) { @@ -205,9 +211,14 @@ export class AssetService { try { while (hasMore) { - const query = this.stack - .asset() - .query({ skip, limit, include_count: true, include_publish_details: true, folder: folderUid }); + const query = this.stack.asset().query({ + skip, + limit, + include_count: true, + include_publish_details: true, + include_asset_scan_status: true, + folder: folderUid, + }); const response = await query.find(); const assets = response.items || []; @@ -273,4 +284,34 @@ export class AssetService { throw error; } } + + /** + * Fetch scan status for a specific list of asset UIDs. + * Batches requests at 100 UIDs per call to stay within API limits. + * Returns a Map — undefined means scanning is not enabled on the stack. + */ + async fetchScanStatusByUIDs(uids: string[]): Promise> { + const statusMap = new Map(); + if (uids.length === 0) return statusMap; + + this.logger.info($t(messages.SCAN_STATUS_FETCHING, { count: uids.length })); + + const BATCH = 100; + for (let i = 0; i < uids.length; i += BATCH) { + const batch = uids.slice(i, i + BATCH); + try { + const response = await this.stack + .asset() + .query({ uid: { $in: batch }, include_asset_scan_status: true, limit: BATCH }) + .find(); + for (const asset of response.items || []) { + statusMap.set(asset.uid, asset._asset_scan_status); + } + } catch (error: any) { + this.logger.warn(`Failed to fetch scan status for batch starting at index ${i}: ${error?.message}`); + } + } + + return statusMap; + } } diff --git a/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts b/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts index 3ce649836..de7f68ff3 100644 --- a/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts +++ b/packages/contentstack-bulk-operations/src/services/bulk-operation-service.ts @@ -47,13 +47,15 @@ export class BulkOperationService { async executeBulkPublish( items: Array, operation: OperationType, - resourceType: ResourceType + resourceType: ResourceType, + environments?: string[], + locales?: string[] ): Promise { this.logger.info($t(messages.SUBMITTING_BULK_JOB, { operation, count: items.length })); try { // Step 1: Submit bulk job - const jobId = await this.submitBulkJob(items, operation, resourceType); + const jobId = await this.submitBulkJob(items, operation, resourceType, environments, locales); this.logger.debug($t(messages.BULK_JOB_CREATED, { jobId })); // Return immediate result after job submission @@ -78,10 +80,12 @@ export class BulkOperationService { private async submitBulkJob( items: Array, operation: OperationType, - resourceType: ResourceType + resourceType: ResourceType, + environments?: string[], + locales?: string[] ): Promise { try { - const payload = this.prepareBulkPayload(items, operation, resourceType); + const payload = this.prepareBulkPayload(items, operation, resourceType, environments, locales); let response: any; switch (operation) { case OperationType.PUBLISH: @@ -203,16 +207,23 @@ export class BulkOperationService { private prepareBulkPayload( items: Array, operation: OperationType, - resourceType: ResourceType + resourceType: ResourceType, + environments?: string[], + locales?: string[] ): any { if (resourceType === ResourceType.ENTRY) { - return this.prepareEntryBulkPayload(items as EntryPublishData[], operation); + return this.prepareEntryBulkPayload(items as EntryPublishData[], operation, environments, locales); } else { - return this.prepareAssetBulkPayload(items as AssetPublishData[], operation); + return this.prepareAssetBulkPayload(items as AssetPublishData[], operation, environments, locales); } } - private prepareEntryBulkPayload(items: EntryPublishData[], operation: OperationType): any { + private prepareEntryBulkPayload( + items: EntryPublishData[], + operation: OperationType, + batchEnvironments?: string[], + batchLocales?: string[] + ): any { const entries = items.map((item) => { const entry: any = { uid: item.uid, @@ -233,8 +244,17 @@ export class BulkOperationService { return entry; }); - const environments = items[0]?.publish_details?.map((pd) => pd.environment) || []; - const locales = Array.from(new Set(items.map((item) => item.locale))); + const environments = batchEnvironments?.length + ? batchEnvironments + : items[0]?.publish_details?.map((pd) => pd.environment) || []; + const locales = batchLocales?.length ? batchLocales : Array.from(new Set(items.map((item) => item.locale))); + + if (!environments.length) { + throw new Error('No environments for bulk publish. Ensure entries have publish_details with environment data.'); + } + if (!locales.length) { + throw new Error('No locales for bulk publish. Ensure entries have a locale field.'); + } return { entries, @@ -244,7 +264,14 @@ export class BulkOperationService { }; } - private prepareAssetBulkPayload(items: AssetPublishData[], operation: OperationType): any { + private prepareAssetBulkPayload( + items: AssetPublishData[], + operation: OperationType, + batchEnvironments?: string[], + batchLocales?: string[] + ): any { + // One item per (uid, locale) reaches here, but the bulk payload keys assets by uid + // and lists locales separately — dedupe so a multi-locale asset is sent once. const seen = new Set(); const assets = items.reduce>((acc, item) => { if (!seen.has(item.uid)) { @@ -254,8 +281,17 @@ export class BulkOperationService { return acc; }, []); - const environments = items[0]?.publish_details?.map((pd) => pd.environment) || []; - const locales = Array.from(new Set(items.map((item) => item.locale))); + const environments = batchEnvironments?.length + ? batchEnvironments + : items[0]?.publish_details?.map((pd) => pd.environment) || []; + const locales = batchLocales?.length ? batchLocales : Array.from(new Set(items.map((item) => item.locale))); + + if (!environments.length) { + throw new Error('No environments for bulk publish. Ensure assets have publish_details with environment data.'); + } + if (!locales.length) { + throw new Error('No locales for bulk publish. Ensure assets have a locale field.'); + } return { assets, diff --git a/packages/contentstack-bulk-operations/src/utils/batch-queue-handler.ts b/packages/contentstack-bulk-operations/src/utils/batch-queue-handler.ts index e32eadcd8..4e624e5da 100644 --- a/packages/contentstack-bulk-operations/src/utils/batch-queue-handler.ts +++ b/packages/contentstack-bulk-operations/src/utils/batch-queue-handler.ts @@ -21,7 +21,7 @@ export function setupBatchQueueListeners(config: BatchQueueConfig) { } logger.info( - `Processing batch ${batch.batchNumber ?? 0}/${batch.totalBatches ?? 0}: ` + + `Processing batch ${batch.batchNumber}/${batch.totalBatches}: ` + `${batch.items.length} items, ` + `${batch.locales.length} locales, ` + `${batch.environments.length} environments` @@ -29,7 +29,13 @@ export function setupBatchQueueListeners(config: BatchQueueConfig) { (async () => { try { - const result = await bulkService.executeBulkPublish(batch.items, batch.operation, resourceType); + const result = await bulkService.executeBulkPublish( + batch.items, + batch.operation, + resourceType, + batch.environments, + batch.locales + ); batchResults.set(item.id, result); queueManager.updateItemStatus(item.id, OperationStatus.SUCCESS); @@ -76,7 +82,7 @@ export function setupBatchQueueListeners(config: BatchQueueConfig) { if (!batch) return; handleAndLogError(error, { - batchNumber: `${batch.batchNumber ?? 0}/${batch.totalBatches ?? 0}`, + batchNumber: `${batch.batchNumber}/${batch.totalBatches}`, itemCount: batch.items.length, }); @@ -109,7 +115,7 @@ async function handleRetryOrFailure({ : retryStrategy.getDelay(item.retryCount); logger.warn( - `Batch ${batch.batchNumber ?? 0}/${batch.totalBatches ?? 0} failed with ${ + `Batch ${batch.batchNumber}/${batch.totalBatches} failed with ${ isRateLimit ? '429 Rate Limit' : getErrorCode(error) }, retrying in ${Math.ceil(delay / 1000)}s` ); diff --git a/packages/contentstack-bulk-operations/src/utils/config-builder.ts b/packages/contentstack-bulk-operations/src/utils/config-builder.ts index 0f877cb69..2521b0776 100644 --- a/packages/contentstack-bulk-operations/src/utils/config-builder.ts +++ b/packages/contentstack-bulk-operations/src/utils/config-builder.ts @@ -69,24 +69,28 @@ function validateConfig(config: BulkOperationConfig): string[] { errors.push(`Invalid operation type: ${config.operation}. Must be 'publish' or 'unpublish'`); } - // Environments validation - if ( - (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && - (!config.environments || config.environments.length === 0) - ) { - errors.push('Environments are required for publish/unpublish operations'); - } - if (config.environments?.some((env) => !env || env.trim() === '')) { - errors.push('Environment list cannot contain empty values'); + // Environments validation — skipped when assets are read from a data directory + if (!config.dataDir) { + if ( + (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && + (!config.environments || config.environments.length === 0) + ) { + errors.push('Environments are required for publish/unpublish operations'); + } + if (config.environments?.some((env) => !env || env.trim() === '')) { + errors.push('Environment list cannot contain empty values'); + } } - // Locales validation + // Locales validation — skipped when assets are read from a data directory const isNonLocalized = config.filter === FilterType.NON_LOCALIZED; - if (!isNonLocalized && (!config.locales || config.locales.length === 0)) { - errors.push('Locales are required'); - } - if (config.locales?.some((locale) => !locale || locale.trim() === '')) { - errors.push('Locale list cannot contain empty values'); + if (!config.dataDir) { + if (!isNonLocalized && (!config.locales || config.locales.length === 0)) { + errors.push('Locales are required'); + } + if (config.locales?.some((locale) => !locale || locale.trim() === '')) { + errors.push('Locale list cannot contain empty values'); + } } // Filter validation @@ -139,24 +143,28 @@ function validateCommandFlags(flags: CommandFlags): string[] { const operation = flags.operation as OperationType; - // Environment validation - if ( - (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && - (!flags.environments || flags.environments.length === 0) - ) { - errors.push('Environments are required for publish/unpublish operations'); - } - if (flags.environments?.some((env) => !env || env.trim() === '')) { - errors.push('Environment list cannot contain empty values'); + // Environment validation — skipped when assets are read from a data directory + if (!flags['data-dir']) { + if ( + (operation === OperationType.PUBLISH || operation === OperationType.UNPUBLISH) && + (!flags.environments || flags.environments.length === 0) + ) { + errors.push('Environments are required for publish/unpublish operations'); + } + if (flags.environments?.some((env) => !env || env.trim() === '')) { + errors.push('Environment list cannot contain empty values'); + } } - // Locale validation + // Locale validation — skipped when assets are read from a data directory const isNonLocalized = flags.filter === FilterType.NON_LOCALIZED; - if (!isNonLocalized && (!flags.locales || flags.locales.length === 0)) { - errors.push('Locales are required'); - } - if (flags.locales?.some((locale) => !locale || locale.trim() === '')) { - errors.push('Locale list cannot contain empty values'); + if (!flags['data-dir']) { + if (!isNonLocalized && (!flags.locales || flags.locales.length === 0)) { + errors.push('Locales are required'); + } + if (flags.locales?.some((locale) => !locale || locale.trim() === '')) { + errors.push('Locale list cannot contain empty values'); + } } // Content types validation @@ -228,6 +236,8 @@ export function buildConfig(flags: CommandFlags): BulkOperationConfig { contentTypes: flags['content-types'] !== undefined ? expandFlagStringList(flags['content-types']) : undefined, includeVariants: flags['include-variants'], folderUid: flags['folder-uid'], + dataDir: flags['data-dir'], + dryRun: flags['dry-run'], sourceEnv: flags['source-env'], publishMode: (flags['publish-mode'] as PublishMode) || PublishMode.BULK, branch: flags.branch || 'main', diff --git a/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts b/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts new file mode 100644 index 000000000..71ea47bd3 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface DataDirScanStats { + /** Number of assets eligible for publish (have publish_details + mapped UID). */ + eligible: number; + /** Total AssetPublishData items that will be created (eligible × locale expansions). */ + totalItems: number; + skipped: number; + unmapped: number; + environments: string[]; + locales: string[]; + /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ + assetUidMapper: Record; + /** Reusable in pass 2 — already loaded during pass 1, avoids a second disk read. */ + assetsIndex: Record; +} + +/** + * Pass 1: count-only scan of the data directory. + * Reads chunk files one at a time, counts eligible/skipped/unmapped, and + * discovers environments and locales — without building any AssetPublishData objects. + * Memory footprint: uid mapper + env map + one chunk at a time. + * + * Returns assetUidMapper and assetsIndex so pass 2 (streamAndPublish) can reuse them + * without re-reading the same files from disk. + */ +export async function scanDataDirStats( + dataDir: string, + overrideEnvs?: string[], + overrideLocales?: string[], + logger?: any +): Promise { + const assetsIndexPath = path.join(dataDir, 'assets', 'assets.json'); + const environmentsPath = path.join(dataDir, 'environments', 'environments.json'); + const assetUidMapperPath = path.join(dataDir, 'mapper', 'assets', 'uid-mapping.json'); + + if (!fs.existsSync(assetsIndexPath)) { + throw new Error( + `Asset index not found: ${assetsIndexPath}. Ensure --data-dir points to the import backup directory.` + ); + } + + let assetUidMapper: Record = {}; + if (fs.existsSync(assetUidMapperPath)) { + assetUidMapper = JSON.parse(fs.readFileSync(assetUidMapperPath, 'utf-8')); + } else { + logger?.warn( + `Asset UID mapper not found: ${assetUidMapperPath}. Ensure --data-dir points to the import backup directory.` + ); + } + + const environmentsMap: Record = {}; + if (fs.existsSync(environmentsPath)) { + const envData: Record = JSON.parse(fs.readFileSync(environmentsPath, 'utf-8')); + for (const [uid, env] of Object.entries(envData)) { + environmentsMap[uid] = (env as any).name || uid; + } + } else { + logger?.warn(`Environments file not found: ${environmentsPath}`); + } + + const assetsIndex: Record = JSON.parse(fs.readFileSync(assetsIndexPath, 'utf-8')); + + let eligible = 0; + let totalItems = 0; + let skipped = 0; + let unmapped = 0; + const allEnvs = new Set(); + const allLocales = new Set(); + + for (const chunkFilename of Object.values(assetsIndex)) { + const chunkPath = path.join(dataDir, 'assets', chunkFilename); + const chunkData: Record = JSON.parse(fs.readFileSync(chunkPath, 'utf-8')); + + for (const asset of Object.values(chunkData)) { + if (!asset.publish_details || asset.publish_details.length === 0) { + skipped++; + continue; + } + + const targetUid = assetUidMapper[asset.uid as string]; + if (!targetUid) { + unmapped++; + continue; + } + + eligible++; + + if (!overrideLocales?.length) { + for (const pd of asset.publish_details) { + if (pd.locale) allLocales.add(pd.locale as string); + } + } + if (!overrideEnvs?.length) { + for (const pd of asset.publish_details) { + const envName = environmentsMap[pd.environment] || pd.environment; + if (envName) allEnvs.add(envName as string); + } + } + + const localeCount = overrideLocales?.length + ? overrideLocales.length + : new Set(asset.publish_details.map((pd: any) => pd.locale as string)).size; + totalItems += localeCount; + } + // chunkData falls out of scope here — GC reclaims it + } + + const environments = overrideEnvs?.length ? overrideEnvs : [...allEnvs]; + const locales = overrideLocales?.length ? overrideLocales : [...allLocales]; + + return { eligible, totalItems, skipped, unmapped, environments, locales, assetUidMapper, assetsIndex }; +} diff --git a/packages/contentstack-bulk-operations/src/utils/helpers.ts b/packages/contentstack-bulk-operations/src/utils/helpers.ts index 7e5beb260..8b7666048 100644 --- a/packages/contentstack-bulk-operations/src/utils/helpers.ts +++ b/packages/contentstack-bulk-operations/src/utils/helpers.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { getLogPath } from '@contentstack/cli-utilities'; import { $t, messages } from './index'; -import { AssetPublishData, EntryPublishData, BulkOperationResult, BulkJobResult } from '../interfaces'; +import { AssetPublishData, EntryPublishData, BulkOperationResult, BulkJobResult, Asset } from '../interfaces'; export function chunkArray(array: T[], chunkSize: number): T[][] { const chunks: T[][] = []; @@ -122,3 +122,37 @@ export function logSummary(result: any): void { console.log(''); } + +/** + * Categorize assets by their _asset_scan_status field. + * Assets with no status field belong to stacks where scanning is disabled — treat as publishable. + */ +export function categorizeByScanStatus(assets: Asset[]): { + clean: Asset[]; + pending: Asset[]; + quarantined: Asset[]; + noStatus: Asset[]; +} { + const clean: Asset[] = []; + const pending: Asset[] = []; + const quarantined: Asset[] = []; + const noStatus: Asset[] = []; + + for (const asset of assets) { + switch (asset._asset_scan_status) { + case 'clean': + clean.push(asset); + break; + case 'pending': + pending.push(asset); + break; + case 'quarantined': + quarantined.push(asset); + break; + default: + noStatus.push(asset); + } + } + + return { clean, pending, quarantined, noStatus }; +} diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index 651b8c755..d22dc8a8f 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -21,6 +21,7 @@ import { aggregateBatchResults, createOperationResult, logSummary, + categorizeByScanStatus, } from './helpers'; import { setupBatchQueueListeners } from './batch-queue-handler'; import { confirmOperation } from './operation-confirmation'; @@ -59,6 +60,8 @@ import { validateAndBuildBulkDeleteItems, LoadAssetUidsError, } from './asset-uids-from-file'; +import { scanDataDirStats } from './data-dir-asset-fetcher'; +import type { DataDirScanStats } from './data-dir-asset-fetcher'; import { compareFieldValues, compareNonLocalizedFields, @@ -98,6 +101,7 @@ export { fetchAssets, fetchEntries, logSummary, + categorizeByScanStatus, logOperationInfo, validateBatch, enqueueIndividualItems, @@ -133,4 +137,6 @@ export { loadBulkDeleteItemsFromFile, validateAndBuildBulkDeleteItems, LoadAssetUidsError, + scanDataDirStats, }; +export type { DataDirScanStats }; diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index 2066c7809..f9bced56f 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -166,13 +166,18 @@ export async function fillMissingFlags(flags: any): Promise { // Track if we prompted for anything let didPrompt = false; + // The presence of --data-dir is what selects the import-backup publish flow: + // environments and locales are then derived per-asset from the backup, so we + // neither prompt for the data-dir path nor for environments/locales here. + const hasDataDir = !!updatedFlags['data-dir']; + // Check if any required fields are missing const needsCredentials = !updatedFlags.alias && !updatedFlags['stack-api-key']; const needsOperation = !updatedFlags.operation; - const needsEnvironments = !updatedFlags.environments || updatedFlags.environments.length === 0; // Check if non-localized filter is used const isNonLocalized = updatedFlags.filter === FilterType.NON_LOCALIZED; - const needsLocales = !isNonLocalized && (!updatedFlags.locales || updatedFlags.locales.length === 0); + const needsEnvironments = !hasDataDir && (!updatedFlags.environments || updatedFlags.environments.length === 0); + const needsLocales = !hasDataDir && !isNonLocalized && (!updatedFlags.locales || updatedFlags.locales.length === 0); // Only show interactive mode header if we need to prompt if (needsCredentials || needsOperation || needsEnvironments || needsLocales) { diff --git a/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts b/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts index 35076ade2..c794520ce 100644 --- a/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts +++ b/packages/contentstack-bulk-operations/src/utils/item-fetcher.ts @@ -221,6 +221,7 @@ export async function fetchAssets( environment: env, locale, })), + _asset_scan_status: asset._asset_scan_status, }); } } diff --git a/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts b/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts index 63dda27af..c6dafc7fb 100644 --- a/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/services/bulk-operation-service.test.ts @@ -133,7 +133,7 @@ describe('BulkOperationService', () => { content_type: 'blog', locale: 'en-us', version: 1, - publish_details: [], + publish_details: [{ environment: 'production', locale: 'en-us', version: 1 }], }, ]; @@ -170,7 +170,7 @@ describe('BulkOperationService', () => { content_type: 'blog', locale: 'en-us', version: 1, - publish_details: [], + publish_details: [{ environment: 'production', locale: 'en-us', version: 1 }], }, ]; @@ -509,14 +509,9 @@ describe('BulkOperationService', () => { } as EntryPublishData, ]; - const payload = (bulkOperationService as any).prepareBulkPayload( - mockItems, - OperationType.PUBLISH, - ResourceType.ENTRY - ); - - expect(payload.entries).to.have.lengthOf(1); - expect(payload.environments).to.deep.equal([]); + expect(() => + (bulkOperationService as any).prepareBulkPayload(mockItems, OperationType.PUBLISH, ResourceType.ENTRY) + ).to.throw('No environments for bulk publish'); }); }); diff --git a/packages/contentstack-cli-cm-regex-validate/package.json b/packages/contentstack-cli-cm-regex-validate/package.json index d3deae21e..57742ce00 100644 --- a/packages/contentstack-cli-cm-regex-validate/package.json +++ b/packages/contentstack-cli-cm-regex-validate/package.json @@ -19,7 +19,7 @@ "dependencies": { "@contentstack/cli-command": "~2.0.0-beta.11", "@contentstack/cli-utilities": "~2.0.0-beta.12", - "@contentstack/management": "^1.30.2", + "@contentstack/management": "^1.30.4", "cli-table3": "^0.6.5", "inquirer": "12.11.1", "jsonexport": "^3.2.0", diff --git a/packages/contentstack-cli-tsgen/package.json b/packages/contentstack-cli-tsgen/package.json index b6037119b..5ed779c6d 100644 --- a/packages/contentstack-cli-tsgen/package.json +++ b/packages/contentstack-cli-tsgen/package.json @@ -71,4 +71,4 @@ "tsgen": "TSGEN" } } -} +} \ No newline at end of file diff --git a/packages/contentstack-clone/README.md b/packages/contentstack-clone/README.md index f3a8c0283..6e6d26834 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.25 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-clone/1.21.11 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-content-type/README.md b/packages/contentstack-content-type/README.md index dfdd50026..da96a0d71 100644 --- a/packages/contentstack-content-type/README.md +++ b/packages/contentstack-content-type/README.md @@ -36,20 +36,17 @@ The Content Type's Display Name, UID, Last Modified Date, and Version number is $ csdx plugins:install contentstack-cli-content-type ``` -## How to use this plugin -This plugin requires you to be authenticated using [csdx auth:login](https://www.contentstack.com/docs/headless-cms/cli-authentication). - -Several commands, such as `csdx content-type:compare` support token aliases as input. -These token aliases should be created using `csdx auth:tokens:add`. - -The commands only use the **Stack API Key**. The management token is ignored. -They are provided as a convenience, so the Stack API Keys do not have to be re-typed. - -## Usability -The `csdx content-type:details` command requires a wide terminal window. If the `path` column is not needed, you can hide it: - -```shell -$ csdx content-type:details -a "management token" -c "content type" --no-path + +```sh-session +$ npm install -g contentstack-cli-content-type +$ csdx COMMAND +running command... +$ csdx (--version) +contentstack-cli-content-type/1.5.4 darwin-arm64 node-v24.18.0 +$ csdx --help [COMMAND] +USAGE + $ csdx COMMAND +... ``` # Commands diff --git a/packages/contentstack-content-type/package.json b/packages/contentstack-content-type/package.json index a97bc9abc..e7702c13d 100644 --- a/packages/contentstack-content-type/package.json +++ b/packages/contentstack-content-type/package.json @@ -14,7 +14,6 @@ "@types/tmp": "^0.2.6", "axios": "^1.18.1", "diff2html": "^3.4.56", - "git-diff": "^2.0.7", "moment": "^2.30.1", "node-graphviz": "^0.1.1", "open": "^8.4.2", @@ -25,8 +24,11 @@ }, "devDependencies": { "@oclif/plugin-help": "^6.2.49", + "@types/diff2html": "^3.0.3", "@types/jest": "^29.5.14", "@types/node": "^22.19.19", + "@types/table": "^6.3.2", + "@types/tmp": "^0.2.6", "eslint": "^10.5.0", "eslint-config-oclif": "^6.0.162", "eslint-config-oclif-typescript": "^3.1.14", diff --git a/packages/contentstack-content-type/skills/code-review/SKILL.md b/packages/contentstack-content-type/skills/code-review/SKILL.md index 9d0809c5f..e5bc5316f 100644 --- a/packages/contentstack-content-type/skills/code-review/SKILL.md +++ b/packages/contentstack-content-type/skills/code-review/SKILL.md @@ -11,7 +11,7 @@ description: >- ## When to use - Reviewing a PR or diff before merge. -- Auditing dependency upgrades (axios, diff2html, git-diff, node-graphviz, tmp, cli-ux). +- Auditing dependency upgrades (axios, diff2html, diff, node-graphviz, tmp, cli-ux). - Changes touching compare HTML, temp files, diagram output, or `src/core/contentstack/`. ## Instructions @@ -24,7 +24,7 @@ Use **Critical** / **Important** / **Suggestion** when leaving feedback. - **Secrets**: Never approve logging of tokens, `authtoken` / `authorization` values, or raw management tokens. - **Compare / diagram**: Changes to [src/core/content-type/compare.ts](../../src/core/content-type/compare.ts) or [diagram.ts](../../src/core/content-type/diagram.ts) deserve extra scrutiny (temp files, browser open, paths, binary dependency). -- **Dependencies**: axios, diff2html, git-diff, node-graphviz, tmp, cli-ux—review changelog and supply-chain for version bumps. +- **Dependencies**: axios, diff2html, diff, node-graphviz, tmp, cli-ux—review changelog and supply-chain for version bumps. - **Quality**: TypeScript and **eslint-config-oclif-typescript** ([.eslintrc](../../.eslintrc)); behavioral changes should include or update **Jest** tests where appropriate. ### Security and privacy @@ -57,7 +57,7 @@ Use **Critical** / **Important** / **Suggestion** when leaving feedback. | Severity | Item | |----------|------| | Important | **axios**: security advisories; upgrade notes. | -| Important | **diff2html**, **git-diff**, **tmp**, **cli-ux**: behavior changes affecting compare UX. | +| Important | **diff2html**, **diff**, **tmp**, **cli-ux**: behavior changes affecting compare UX. | | Important | **node-graphviz**: compatibility with supported Node and system Graphviz. | | Suggestion | **moment** (if touched): prefer minimal churn; note maintenance status of dependencies. | diff --git a/packages/contentstack-content-type/skills/contentstack-cli-content-type/SKILL.md b/packages/contentstack-content-type/skills/contentstack-cli-content-type/SKILL.md index 4434d876d..aaf02cf86 100644 --- a/packages/contentstack-content-type/skills/contentstack-cli-content-type/SKILL.md +++ b/packages/contentstack-content-type/skills/contentstack-cli-content-type/SKILL.md @@ -100,7 +100,7 @@ Errors: response `data.errors` → `ContentstackError`; optional suffix with sta ### Compare and diagram pipelines -- **Compare**: `core/content-type/compare.ts` builds a unified diff from two JSON snapshots (`git-diff`), parses with **diff2html**, writes a **temporary HTML** file, opens it in the browser (`cli-ux` / `cli.open`). Not a terminal table. +- **Compare**: `core/content-type/compare.ts` builds a unified diff from two JSON snapshots (`diff`), parses with **diff2html**, writes a **temporary HTML** file, opens it in the browser (`cli-ux` / `cli.open`). Not a terminal table. - **Diagram**: `core/content-type/diagram.ts` builds a DOT graph, runs **node-graphviz** (`graphviz` binary must be available on the system for SVG rendering). Output path is sanitized where utilities apply. ### Commands (flags and behavior) diff --git a/packages/contentstack-content-type/src/core/content-type/compare.ts b/packages/contentstack-content-type/src/core/content-type/compare.ts index b3229164f..d3b18194b 100644 --- a/packages/contentstack-content-type/src/core/content-type/compare.ts +++ b/packages/contentstack-content-type/src/core/content-type/compare.ts @@ -2,7 +2,7 @@ import open from 'open' import * as fs from 'fs' import * as tmp from 'tmp' import * as Diff2html from 'diff2html' -import gitDiff from 'git-diff' +import {createTwoFilesPatch} from 'diff' import {BuildOutput} from '../../types' export default async function buildOutput(contentTypeName: string, previous: any, current: any): Promise { @@ -30,10 +30,13 @@ export default async function buildOutput(contentTypeName: string, previous: any } function buildDiffString(previous: any, current: any) { - return ( - `--- ${previous.uid}\t${current.updated_at}\n` + - `+++ ${current.uid}\t${current.updated_at}\n` + - gitDiff(JSON.stringify(previous, null, 2), JSON.stringify(current, null, 2)) + return createTwoFilesPatch( + previous.uid, + current.uid, + JSON.stringify(previous, null, 2), + JSON.stringify(current, null, 2), + current.updated_at, + current.updated_at, ) } diff --git a/packages/contentstack-content-type/test/core/content-type/compare.test.ts b/packages/contentstack-content-type/test/core/content-type/compare.test.ts index 74f818079..6d7f8d811 100644 --- a/packages/contentstack-content-type/test/core/content-type/compare.test.ts +++ b/packages/contentstack-content-type/test/core/content-type/compare.test.ts @@ -20,11 +20,8 @@ jest.mock('diff2html', () => ({ html: jest.fn(() => '
diff-html
'), })) -jest.mock('git-diff', () => - jest.fn(() => '@@ -1 +1 @@\n+changed') -) - import fs from 'fs' +import * as Diff2html from 'diff2html' import buildOutput from '../../../src/core/content-type/compare' @@ -48,4 +45,23 @@ describe('compare buildOutput', () => { expect(written).toContain('diff-html') expect(written).toContain('diff2html') }) + + it('feeds diff2html a unified patch with file headers and the changed lines', async () => { + await buildOutput('my-ct', prev, curr) + + const patch = (Diff2html.parse as jest.Mock).mock.calls[0][0] as string + expect(patch).toContain(`--- ${prev.uid}\t${curr.updated_at}`) + expect(patch).toContain(`+++ ${curr.uid}\t${curr.updated_at}`) + expect(patch).toMatch(/^@@ .* @@$/m) + expect(patch).toContain('- "updated_at": "2020-01-01"') + expect(patch).toContain('+ "updated_at": "2021-01-01"') + }) + + it('produces a patch with no hunks when both versions are identical', async () => { + await buildOutput('my-ct', prev, prev) + + const patch = (Diff2html.parse as jest.Mock).mock.calls[0][0] as string + expect(patch).not.toContain('@@') + expect(patch).not.toContain('undefined') + }) }) diff --git a/packages/contentstack-export-to-csv/README.md b/packages/contentstack-export-to-csv/README.md index f7b550f43..5649c6fc3 100644 --- a/packages/contentstack-export-to-csv/README.md +++ b/packages/contentstack-export-to-csv/README.md @@ -18,8 +18,8 @@ Refer to the [Export Content to .CSV](https://www.contentstack.com/docs/headless $ npm install -g @contentstack/cli-cm-export-to-csv $ csdx COMMAND running command... -$ csdx (-v|--version|version) -@contentstack/cli-cm-export-to-csv/2.0.0-beta.9 darwin-arm64 node-v22.21.1 +$ csdx (--version) +@contentstack/cli-cm-export-to-csv/1.12.8 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-export-to-csv/package.json b/packages/contentstack-export-to-csv/package.json index dfad8c39d..7a449b4d7 100644 --- a/packages/contentstack-export-to-csv/package.json +++ b/packages/contentstack-export-to-csv/package.json @@ -74,4 +74,4 @@ "version": "oclif readme && git add README.md", "format": "eslint \"src/**/*.ts\" --fix" } -} +} \ No newline at end of file diff --git a/packages/contentstack-export/README.md b/packages/contentstack-export/README.md index 492c622b9..4de8ba33a 100755 --- a/packages/contentstack-export/README.md +++ b/packages/contentstack-export/README.md @@ -20,7 +20,7 @@ $ npm install -g @contentstack/cli-cm-export $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-export/2.0.0-beta.24 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-export/1.27.0 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-export/messages/index.json b/packages/contentstack-export/messages/index.json index 241e651ba..f9e58b6a2 100644 --- a/packages/contentstack-export/messages/index.json +++ b/packages/contentstack-export/messages/index.json @@ -10,6 +10,8 @@ "ASSET_QUERY_FAILED": "Failed to query asset data from the API", "ASSET_VERSIONED_QUERY_FAILED": "Failed to query versioned asset data from the API", "ASSET_COUNT_QUERY_FAILED": "Failed to retrieve total asset count", + "ASSET_SCAN_SKIPPED": "Skipping download of asset '%s' (UID: %s) — scan status: %s", + "ASSET_SCAN_SKIP_SUMMARY": "%s asset(s) skipped due to a non-clean scan status (pending/quarantined). Re-run the export once scanning completes to download them.", "CONTENT_TYPE_EXPORT_COMPLETE": "Content types exported successfully", "CONTENT_TYPE_NO_TYPES": "No content types found", diff --git a/packages/contentstack-export/package.json b/packages/contentstack-export/package.json index e9ff022ec..d81146c41 100644 --- a/packages/contentstack-export/package.json +++ b/packages/contentstack-export/package.json @@ -25,6 +25,7 @@ "@contentstack/cli-auth": "~2.0.0-beta.17", "@contentstack/cli-config": "~2.0.0-beta.15", "@oclif/plugin-help": "^6.2.49", + "@contentstack/cli-dev-dependencies": "^1.3.1", "@oclif/test": "^4.1.18", "@types/big-json": "^3.2.5", "@types/chai": "^4.3.11", @@ -90,13 +91,11 @@ "shortCommandName": { "cm:stacks:export": "EXPRT" }, - "planProtectedFeatures": [ - "amAssets" - ] + "planProtectedFeatures": ["assetsScan", "amAssets"] }, "repository": { "type": "git", "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-export" } -} +} \ No newline at end of file diff --git a/packages/contentstack-export/src/config/index.ts b/packages/contentstack-export/src/config/index.ts index abca361f1..1699c2352 100644 --- a/packages/contentstack-export/src/config/index.ts +++ b/packages/contentstack-export/src/config/index.ts @@ -117,6 +117,9 @@ const config: DefaultConfig = { displayExecutionTime: false, enableDownloadStatus: false, includeVersionedAssets: false, + // Asset scan statuses that must block download; any other value (including 'not_scanned', + // 'clean', or the field being absent) is treated as safe to download. + blockingScanStatuses: ['pending', 'quarantined'], }, 'cs-assets': { chunkFileSizeMb: 1, diff --git a/packages/contentstack-export/src/export/modules/assets.ts b/packages/contentstack-export/src/export/modules/assets.ts index ffcfdab6a..183d66a56 100644 --- a/packages/contentstack-export/src/export/modules/assets.ts +++ b/packages/contentstack-export/src/export/modules/assets.ts @@ -365,6 +365,7 @@ export default class ExportAssets extends BaseClass { const queryParam = { ...this.commonQueryParam, include_publish_details: true, + include_asset_scan_status: true, except: { BASE: this.assetConfig.invalidKeys }, }; this.applyQueryFilters(queryParam, 'assets'); @@ -404,7 +405,10 @@ export default class ExportAssets extends BaseClass { indexFileName: this.assetConfig.fileName, basePath: this.assetsRootPath, chunkFileSize: this.assetConfig.chunkFileSize, - metaPickKeys: merge(['uid', 'url', 'filename', 'parent_uid'], this.assetConfig.assetsMetaKeys), + metaPickKeys: merge( + ['uid', 'url', 'filename', 'parent_uid', '_asset_scan_status'], + this.assetConfig.assetsMetaKeys, + ), }); } if (!isEmpty(items)) { @@ -444,6 +448,7 @@ export default class ExportAssets extends BaseClass { const queryParam = { ...this.commonQueryParam, include_publish_details: true, + include_asset_scan_status: true, except: { BASE: this.assetConfig.invalidKeys }, }; @@ -483,7 +488,10 @@ export default class ExportAssets extends BaseClass { indexFileName: PATH_CONSTANTS.FILES.VERSIONED_ASSETS, chunkFileSize: this.assetConfig.chunkFileSize, basePath: pResolve(this.assetsRootPath, 'versions'), - metaPickKeys: merge(['uid', 'url', 'filename', '_version', 'parent_uid'], this.assetConfig.assetsMetaKeys), + metaPickKeys: merge( + ['uid', 'url', 'filename', '_version', 'parent_uid', '_asset_scan_status'], + this.assetConfig.assetsMetaKeys, + ), }); } if (!isEmpty(response)) { @@ -569,6 +577,19 @@ export default class ExportAssets extends BaseClass { listOfAssets = uniqBy(listOfAssets, 'url'); log.debug(`Total unique assets to download: ${listOfAssets.length}`, this.exportConfig.context); + const isNotClean = (asset: any) => this.assetConfig.blockingScanStatuses.includes(asset._asset_scan_status); + const skippedAssets = filter(listOfAssets, isNotClean); + listOfAssets = filter(listOfAssets, (asset: any) => !isNotClean(asset)); + + if (!isEmpty(skippedAssets)) { + for (const asset of skippedAssets) { + log.warn( + messageHandler.parse('ASSET_SCAN_SKIPPED', asset.filename, asset.uid, asset._asset_scan_status), + this.exportConfig.context, + ); + } + } + const apiBatches: Array = chunk(listOfAssets, this.assetConfig.downloadLimit); const downloadedAssetsDirs = await getDirectories(pResolve(this.assetsRootPath, 'files')); @@ -664,6 +685,9 @@ export default class ExportAssets extends BaseClass { promisifyHandler, ).then(() => { log.success(messageHandler.parse('ASSET_DOWNLOAD_COMPLETE'), this.exportConfig.context); + if (!isEmpty(skippedAssets)) { + log.warn(messageHandler.parse('ASSET_SCAN_SKIP_SUMMARY', skippedAssets.length), this.exportConfig.context); + } }); } } diff --git a/packages/contentstack-export/src/export/modules/base-class.ts b/packages/contentstack-export/src/export/modules/base-class.ts index c4ea0e076..d591e3355 100644 --- a/packages/contentstack-export/src/export/modules/base-class.ts +++ b/packages/contentstack-export/src/export/modules/base-class.ts @@ -5,8 +5,13 @@ import chunk from 'lodash/chunk'; import isEmpty from 'lodash/isEmpty'; import entries from 'lodash/entries'; import isEqual from 'lodash/isEqual'; -import { log, CLIProgressManager, configHandler, getSessionLogPath } from '@contentstack/cli-utilities'; - +import { + log, + CLIProgressManager, + configHandler, + getSessionLogPath, + handleAndLogError, +} from '@contentstack/cli-utilities'; import { ExportConfig, ModuleClassParams } from '../../types'; export type ApiOptions = { @@ -73,7 +78,7 @@ export default abstract class BaseClass { } /** - * Create simple progress manager + * Create simple progress manager */ protected createSimpleProgress(moduleName: string, total?: number): CLIProgressManager { this.currentModuleName = moduleName; @@ -121,7 +126,9 @@ export default abstract class BaseClass { // Generate default messages if not provided const successMessage = options?.customSuccessMessage || `${name} have been exported successfully!`; const sessionLogPath = getSessionLogPath(); - const warningMessage = options?.customWarningMessage || `${name} have been exported with some errors. Please check the logs at: ${sessionLogPath}`; + const warningMessage = + options?.customWarningMessage || + `${name} have been exported with some errors. Please check the logs at: ${sessionLogPath}`; this.completeProgress(true); @@ -199,7 +206,16 @@ export default abstract class BaseClass { } /* eslint-disable no-await-in-loop */ - await Promise.allSettled(allPromise); + const settledResults = await Promise.allSettled(allPromise); + settledResults.forEach((result) => { + if (result.status === 'rejected') { + handleAndLogError( + result.reason, + { ...this.exportConfig.context }, + `Unhandled rejection in '${module}' batch ${batchNo}`, + ); + } + }); /* eslint-disable no-await-in-loop */ await this.logMsgAndWaitIfRequired(module, start, batchNo); @@ -234,6 +250,30 @@ export default abstract class BaseClass { if (exeTime < 1000) await this.delay(1000 - exeTime); } + /** + * Wraps a caller-supplied resolve/reject callback so that if the callback itself throws + * (e.g. a bug while writing/logging the result), the failure is caught right here and + * always recorded via handleAndLogError, instead of turning into a rejected promise that + * a caller further up the chain may or may not notice. + */ + private guardCallback( + callback: (value: any) => void, + moduleName: ApiModuleType, + context: Record, + ): (value: any) => void { + return (value: any) => { + try { + callback(value); + } catch (error) { + handleAndLogError( + error, + { ...this.exportConfig.context, ...context }, + `Unhandled error while processing '${moduleName}' API result`, + ); + } + }; + } + /** * @method makeAPICall * @param {Record} options - Api related params @@ -244,32 +284,35 @@ export default abstract class BaseClass { { module: moduleName, reject, resolve, url = '', uid = '', additionalInfo, queryParam = {} }: ApiOptions, isLastRequest = false, ): Promise { + const safeResolve = this.guardCallback(resolve, moduleName, { uid, additionalInfo }); + const safeReject = this.guardCallback(reject, moduleName, { uid, additionalInfo }); + switch (moduleName) { case 'asset': return this.stack .asset(uid) .fetch(queryParam) - .then((response: any) => resolve({ response, isLastRequest, additionalInfo })) - .catch((error: Error) => reject({ error, isLastRequest, additionalInfo })); + .then((response: any) => safeResolve({ response, isLastRequest, additionalInfo })) + .catch((error: Error) => safeReject({ error, isLastRequest, additionalInfo })); case 'assets': return this.stack .asset() .query(queryParam) .find() - .then((response: any) => resolve({ response, isLastRequest, additionalInfo })) - .catch((error: Error) => reject({ error, isLastRequest, additionalInfo })); + .then((response: any) => safeResolve({ response, isLastRequest, additionalInfo })) + .catch((error: Error) => safeReject({ error, isLastRequest, additionalInfo })); case 'download-asset': return this.stack .asset() .download({ url, responseType: 'stream' }) - .then((response: any) => resolve({ response, isLastRequest, additionalInfo })) - .catch((error: any) => reject({ error, isLastRequest, additionalInfo })); + .then((response: any) => safeResolve({ response, isLastRequest, additionalInfo })) + .catch((error: any) => safeReject({ error, isLastRequest, additionalInfo })); case 'export-taxonomy': return this.stack .taxonomy(uid) .export(queryParam) - .then((response: any) => resolve({ response, uid })) - .catch((error: any) => reject({ error, uid })); + .then((response: any) => safeResolve({ response, uid })) + .catch((error: any) => safeReject({ error, uid })); default: return Promise.resolve(); } diff --git a/packages/contentstack-export/src/types/default-config.ts b/packages/contentstack-export/src/types/default-config.ts index 0c684b702..b0c09432f 100644 --- a/packages/contentstack-export/src/types/default-config.ts +++ b/packages/contentstack-export/src/types/default-config.ts @@ -101,6 +101,7 @@ export default interface DefaultConfig { displayExecutionTime: boolean; enableDownloadStatus: boolean; includeVersionedAssets: boolean; + blockingScanStatuses: string[]; dependencies?: Modules[]; }; 'cs-assets': { diff --git a/packages/contentstack-export/src/utils/export-config-handler.ts b/packages/contentstack-export/src/utils/export-config-handler.ts index bc4f5ec83..522bc9990 100644 --- a/packages/contentstack-export/src/utils/export-config-handler.ts +++ b/packages/contentstack-export/src/utils/export-config-handler.ts @@ -6,8 +6,8 @@ import { cliux, sanitizePath, log, - isFeatureEnabled, FeatureCtx, + isFeatureEnabled, } from '@contentstack/cli-utilities'; import defaultConfig from '../config'; import { readFile, isDirectoryNonEmpty } from './file-helper'; @@ -37,12 +37,9 @@ const setupConfig = async (exportCmdFlags: any, context?: any): Promise { displayExecutionTime: false, enableDownloadStatus: false, includeVersionedAssets: false, + blockingScanStatuses: ['pending', 'quarantined'], }, 'cs-assets': { chunkFileSizeMb: 1, @@ -622,6 +623,46 @@ describe('ExportAssets', () => { expect(makeConcurrentCallStub.called).to.be.true; }); + + it('should skip assets with a non-clean scan status and download the rest', async () => { + getPlainMetaStub.returns({ + 'file-1': [ + { uid: 'clean-1', url: 'https://test.io/assets/clean-1.jpeg', filename: 'clean-1.jpeg', _asset_scan_status: 'clean' }, + { uid: 'pending-1', url: 'https://test.io/assets/pending-1.zip', filename: 'pending-1.zip', _asset_scan_status: 'pending' }, + { uid: 'quarantined-1', url: 'https://test.io/assets/quarantined-1.zip', filename: 'quarantined-1.zip', _asset_scan_status: 'quarantined' }, + ], + }); + + await exportAssets.downloadAssets(); + + expect(makeConcurrentCallStub.called).to.be.true; + // Only the 'clean' asset should be handed off for download; pending/quarantined are skipped. + expect(makeConcurrentCallStub.firstCall.args[0].totalCount).to.equal(1); + }); + + it('should download assets with no scan status field (stacks without asset scanning enabled)', async () => { + getPlainMetaStub.returns({ + 'file-1': [{ uid: 'legacy-1', url: 'https://test.io/assets/legacy-1.jpeg', filename: 'legacy-1.jpeg' }], + }); + + await exportAssets.downloadAssets(); + + // Missing _asset_scan_status must not be treated as non-clean, or every export would break. + expect(makeConcurrentCallStub.firstCall.args[0].totalCount).to.equal(1); + }); + + it('should download assets with a not_scanned status (org has asset scanning disabled)', async () => { + getPlainMetaStub.returns({ + 'file-1': [ + { uid: 'not-scanned-1', url: 'https://test.io/assets/not-scanned-1.jpeg', filename: 'not-scanned-1.jpeg', _asset_scan_status: 'not_scanned' }, + ], + }); + + await exportAssets.downloadAssets(); + + // 'not_scanned' means scanning is off for the org, not that the asset is unsafe. + expect(makeConcurrentCallStub.firstCall.args[0].totalCount).to.equal(1); + }); }); describe('Edge Cases', () => { 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 c9d7961da..cc29d35a9 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 @@ -158,6 +158,7 @@ describe('BaseClass', () => { displayExecutionTime: false, enableDownloadStatus: false, includeVersionedAssets: false, + blockingScanStatuses: ['pending', 'quarantined'], }, 'cs-assets': { chunkFileSizeMb: 1, diff --git a/packages/contentstack-export/test/unit/export/modules/stack.test.ts b/packages/contentstack-export/test/unit/export/modules/stack.test.ts index bfce47326..c6b369a21 100644 --- a/packages/contentstack-export/test/unit/export/modules/stack.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/stack.test.ts @@ -148,6 +148,8 @@ describe('ExportStack', () => { displayExecutionTime: false, enableDownloadStatus: false, includeVersionedAssets: false, + + blockingScanStatuses: ['pending', 'quarantined'], }, content_types: { dirName: 'content_types', diff --git a/packages/contentstack-external-migrate/package.json b/packages/contentstack-external-migrate/package.json index 85d1fbe87..b96746380 100644 --- a/packages/contentstack-external-migrate/package.json +++ b/packages/contentstack-external-migrate/package.json @@ -83,4 +83,4 @@ "engines": { "node": ">=22.0.0" } -} +} \ No newline at end of file diff --git a/packages/contentstack-import-setup/README.md b/packages/contentstack-import-setup/README.md index c2c0db5fb..1e4b7c1be 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.18 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-import-setup/1.8.8 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-import-setup/package.json b/packages/contentstack-import-setup/package.json index 651f7682f..88c32a0fc 100644 --- a/packages/contentstack-import-setup/package.json +++ b/packages/contentstack-import-setup/package.json @@ -84,4 +84,4 @@ "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-import-setup" } -} +} \ No newline at end of file diff --git a/packages/contentstack-import/README.md b/packages/contentstack-import/README.md index 434fd55fa..0ac291160 100644 --- a/packages/contentstack-import/README.md +++ b/packages/contentstack-import/README.md @@ -10,7 +10,7 @@ It is Contentstack’s CLI plugin to import content in the stack. To learn how t Use `csdx config:set:region` to set your region. Run `csdx config:set:region --help` for available regions. - +P # Usage @@ -19,7 +19,7 @@ $ npm install -g @contentstack/cli-cm-import $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-import/2.0.0-beta.24 darwin-arm64 node-v24.14.0 +@contentstack/cli-cm-import/1.35.0 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-import/package.json b/packages/contentstack-import/package.json index aeaad8efe..44dd80987 100644 --- a/packages/contentstack-import/package.json +++ b/packages/contentstack-import/package.json @@ -84,11 +84,13 @@ "csdxConfig": { "shortCommandName": { "cm:stacks:import": "IMPRT" - } + }, + "planProtectedFeatures": ["assetsScan"] + }, "repository": { "type": "git", "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-import" } -} +} \ No newline at end of file diff --git a/packages/contentstack-import/src/commands/cm/stacks/import.ts b/packages/contentstack-import/src/commands/cm/stacks/import.ts index 6a2448450..e2b7a0cad 100644 --- a/packages/contentstack-import/src/commands/cm/stacks/import.ts +++ b/packages/contentstack-import/src/commands/cm/stacks/import.ts @@ -144,7 +144,7 @@ export default class ImportCommand extends Command { let importConfig: ImportConfig; try { const { flags } = await this.parse(ImportCommand); - importConfig = await setupImportConfig(flags); + importConfig = await setupImportConfig(flags, this.context); // Prepare the context object createLogContext( this.context?.info?.command || 'cm:stacks:import', @@ -175,10 +175,25 @@ export default class ImportCommand extends Command { } const moduleImporter = new ModuleImporter(managementAPIClient, importConfig); - await moduleImporter.start(); + const result = await moduleImporter.start(); backupDir = importConfig.backupDir; //Note: Final summary is now handled by summary manager CLIProgressManager.printGlobalSummary(); + + // Closing reminder: only relevant when assets were actually part of this + // run and scanning is why they're unpublished — assetScanningEnabled is an + // org-plan flag and isn't scoped to which modules this run touched. + const assetsImported = importConfig.moduleName + ? importConfig.moduleName === 'assets' + : importConfig.modules?.types?.includes('assets'); + if (!result?.noSuccessMsg && assetsImported && importConfig.assetScanningEnabled) { + cliux.print('\nAsset Scanning is enabled — assets were not published.', { color: 'yellow' }); + cliux.print(' Once scanning completes, publish your assets using:', { color: 'yellow' }); + cliux.print( + ` csdx cm:stacks:bulk-assets --data-dir ${backupDir} --stack-api-key ${importConfig.apiKey} --operation publish`, + { color: 'cyan' }, + ); + } this.logSuccessAndBackupMessages(backupDir, importConfig); // Clear progress module setting now that import is complete clearProgressModuleSetting(); diff --git a/packages/contentstack-import/src/import/modules/assets.ts b/packages/contentstack-import/src/import/modules/assets.ts index b2afa06b8..373cfa0dd 100644 --- a/packages/contentstack-import/src/import/modules/assets.ts +++ b/packages/contentstack-import/src/import/modules/assets.ts @@ -4,11 +4,10 @@ import filter from 'lodash/filter'; import unionBy from 'lodash/unionBy'; import orderBy from 'lodash/orderBy'; import isEmpty from 'lodash/isEmpty'; -import uniq from 'lodash/uniq'; import { existsSync } from 'node:fs'; import includes from 'lodash/includes'; import { resolve as pResolve, join } from 'node:path'; -import { FsUtility, log, handleAndLogError, generateUid } from '@contentstack/cli-utilities'; +import { FsUtility, log, handleAndLogError, generateUid, FeatureStatus } from '@contentstack/cli-utilities'; import { ImportSpaces, type SpaceMapping } from '@contentstack/cli-asset-management'; import { PATH_CONSTANTS } from '../../constants'; @@ -39,11 +38,13 @@ export default class ImportAssets extends BaseClass { private assetsUrlMap: Record = {}; private assetsFolderMap: Record = {}; private rootFolder: { uid: string; name: string; parent_uid: string; created_at: string }; + private planStatus: Record = {}; constructor({ importConfig, stackAPIClient }: ModuleClassParams) { super({ importConfig, stackAPIClient }); this.importConfig.context.module = MODULE_CONTEXTS.ASSETS; this.currentModuleName = MODULE_NAMES[MODULE_CONTEXTS.ASSETS]; + this.planStatus = this.importConfig.planStatus || {}; this.assetsPath = join(this.importConfig.backupDir, PATH_CONSTANTS.CONTENT_DIRS.ASSETS); this.mapperDirPath = join(this.importConfig.backupDir, PATH_CONSTANTS.MAPPER, PATH_CONSTANTS.MAPPER_MODULES.ASSETS); @@ -66,6 +67,10 @@ export default class ImportAssets extends BaseClass { try { log.debug('Starting assets import process...', this.importConfig.context); + if (this.planStatus['assetsScan']?.is_part_of_plan) { + log.info('Assets Scanning is enabled in this stack', this.importConfig.context); + log.warn('Assets publishing will be skipped', this.importConfig.context); + } // CS Assets: csAssetsEnabled is set in the config handler when spaces/ + am_v2 are detected. if (this.importConfig.csAssetsEnabled) { if (!this.importConfig.csAssetsUrl) { @@ -201,6 +206,16 @@ export default class ImportAssets extends BaseClass { this.completeProgress(true); log.success('Assets imported successfully!', this.importConfig.context); + + if (this.importConfig.assetScanningEnabled) { + log.info('Asset Scanning is enabled for this stack.', this.importConfig.context); + log.info('Assets cannot be published immediately — scanning must complete first.', this.importConfig.context); + log.info('Once scanning is done, publish your assets using:', this.importConfig.context); + log.info( + 'csdx cm:stacks:bulk-assets --data-dir ./content --stack-api-key --operation publish', + this.importConfig.context, + ); + } } catch (error) { this.completeProgress(false, error?.message || 'Asset import failed'); handleAndLogError(error, { ...this.importConfig.context }); @@ -725,31 +740,10 @@ export default class ImportAssets extends BaseClass { handleAndLogError(error, { ...this.importConfig.context, uid, title }); }; + // apiData is a pre-expanded sub-item ({ uid, title, publishDetails }); one per env-locale-set + // group (see below). Pairing is already preserved, so this only resolves the destination UID. const serializeData = (apiOptions: ApiOptions) => { const { apiData: asset } = apiOptions; - const publishDetails = filter(asset.publish_details, ({ environment }) => { - return this.environments?.hasOwnProperty(environment); - }); - - if (publishDetails.length) { - const environments = uniq(map(publishDetails, ({ environment }) => this.environments[environment].name)); - const locales = uniq(map(publishDetails, 'locale')); - - if (environments.length === 0 || locales.length === 0) { - log.debug( - `Skipping publish for asset ${asset.uid} - no valid environments/locales`, - this.importConfig.context, - ); - apiOptions.entity = undefined; - return apiOptions; - } - - asset.locales = locales; - asset.environments = environments; - apiOptions.apiData.publishDetails = { locales, environments }; - log.debug(`Prepared publish details for asset ${asset.uid}`, this.importConfig.context); - } - apiOptions.uid = this.assetsUidMap[asset.uid] as string; if (!apiOptions.uid) { @@ -762,12 +756,17 @@ export default class ImportAssets extends BaseClass { for (const index in indexer) { log.debug(`Processing publish chunk ${index} of ${indexerCount}`, this.importConfig.context); - const apiContent = filter( - values(await fs.readChunkFiles.next()), - ({ publish_details }) => !isEmpty(publish_details), + // Expand each asset into one sub-item per env-locale-set group, so each makeConcurrentCall + // item is a single-rectangle publish (preserves env↔locale pairing — DX-9772). + const apiContent = values(await fs.readChunkFiles.next()).flatMap((asset: Record) => + this.buildPublishGroups(asset.publish_details).map((publishDetails) => ({ + uid: asset.uid, + title: asset.title, + publishDetails, + })), ); - log.debug(`Found ${apiContent.length} publishable assets in chunk`, this.importConfig.context); + log.debug(`Found ${apiContent.length} asset publish calls in chunk`, this.importConfig.context); await this.makeConcurrentCall({ apiContent, diff --git a/packages/contentstack-import/src/types/import-config.ts b/packages/contentstack-import/src/types/import-config.ts index 847010f49..ebc8ec7b9 100644 --- a/packages/contentstack-import/src/types/import-config.ts +++ b/packages/contentstack-import/src/types/import-config.ts @@ -1,3 +1,4 @@ +import { FeatureStatus } from '@contentstack/cli-utilities'; import { Context, Modules, Region } from '.'; import DefaultConfig from './default-config'; @@ -16,6 +17,7 @@ export default interface ImportConfig extends DefaultConfig, ExternalConfig { skipEntriesPublish?: boolean; skipTaxonomyPublish?: boolean; cliLogsPath: string; + assetScanningEnabled?: boolean; canCreatePrivateApp: boolean; contentDir: string; data: string; @@ -60,6 +62,8 @@ export default interface ImportConfig extends DefaultConfig, ExternalConfig { context: Context; csAssetsUrl?: string; csAssetsEnabled?: boolean; + + planStatus?: Record; } type branch = { diff --git a/packages/contentstack-import/src/utils/import-config-handler.ts b/packages/contentstack-import/src/utils/import-config-handler.ts index 8126ee580..244ca4002 100644 --- a/packages/contentstack-import/src/utils/import-config-handler.ts +++ b/packages/contentstack-import/src/utils/import-config-handler.ts @@ -1,7 +1,15 @@ import merge from 'merge'; import * as path from 'path'; import { omit, filter, includes, isArray } from 'lodash'; -import { configHandler, isAuthenticated, cliux, sanitizePath, log } from '@contentstack/cli-utilities'; +import { + configHandler, + isAuthenticated, + cliux, + sanitizePath, + log, + isFeatureEnabled, + FeatureCtx, +} from '@contentstack/cli-utilities'; import defaultConfig from '../config'; import { readFile, readFileSync } from './file-helper'; import { askContentDir, askAPIKey } from './interactive'; @@ -9,7 +17,7 @@ import login from './login-handler'; import { ImportConfig } from '../types'; import { existsSync } from 'fs'; -const setupConfig = async (importCmdFlags: any): Promise => { +const setupConfig = async (importCmdFlags: any, context?: any): Promise => { // Set progress supported module FIRST, before any log calls // This ensures the logger respects the showConsoleLogs setting correctly configHandler.set('log.progressSupportedModule', 'import'); @@ -167,6 +175,33 @@ const setupConfig = async (importCmdFlags: any): Promise => { config.authenticationMethod = authenticationMethod; log.debug('Import configuration setup completed.', { ...config }); + // Deferred plan check — credentials now available after setupImportConfig + const deferredFeatures: string[] = context?.planCheckRequired ?? []; + if (deferredFeatures.length > 0) { + const planCtx: FeatureCtx = { + apiKey: config.apiKey, + managementToken: config.management_token, + authToken: config.auth_token, + }; + for (const featureUid of deferredFeatures) { + try { + const status = await isFeatureEnabled(featureUid, planCtx); + if (context) context.planStatus[featureUid] = status; + log.debug(`[import] Deferred plan status fetched for "${featureUid}".`); + } catch (error) { + log.warn(`[import] Could not fetch deferred plan status for "${featureUid}": ${(error as Error).message}`); + } + } + } + + if (context?.planStatus) { + config.planStatus = context.planStatus; + if (config.planStatus['assetsScan']?.is_part_of_plan) { + config.assetScanningEnabled = true; + config.skipAssetsPublish = true; + } + } + return config; }; diff --git a/packages/contentstack-import/test/unit/import/modules/assets.test.ts b/packages/contentstack-import/test/unit/import/modules/assets.test.ts index c33b37854..160e8155d 100644 --- a/packages/contentstack-import/test/unit/import/modules/assets.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/assets.test.ts @@ -674,53 +674,16 @@ describe('ImportAssets', () => { expect(makeConcurrentCallStub.called).to.be.true; }); - it('should filter publish_details by valid environments', async () => { - importAssets['environments'] = { 'env-1': { name: 'production' } }; - - makeConcurrentCallStub.callsFake(async (options: any) => { - const serializeData = options.apiParams.serializeData; - const result = serializeData({ - apiData: { - uid: 'asset-1', - publish_details: [ - { environment: 'env-1', locale: 'en-us' }, - { environment: 'env-invalid', locale: 'en-us' }, - ], - }, - }); - - expect(result.apiData.environments).to.deep.equal(['production']); - expect(result.apiData.locales).to.deep.equal(['en-us']); - }); - - await (importAssets as any).publish(); - }); - - it('should skip publish when no valid environments', async () => { - makeConcurrentCallStub.callsFake(async (options: any) => { - const serializeData = options.apiParams.serializeData; - const result = serializeData({ - apiData: { - uid: 'asset-1', - publish_details: [{ environment: 'env-invalid', locale: 'en-us' }], - }, - }); - - expect(result.entity).to.be.undefined; - }); - - await (importAssets as any).publish(); - }); - it('should skip publish when no UID mapping found', async () => { importAssets['assetsUidMap'] = {}; makeConcurrentCallStub.callsFake(async (options: any) => { const serializeData = options.apiParams.serializeData; + // apiData is now a pre-grouped sub-item. const result = serializeData({ apiData: { uid: 'asset-unknown', - publish_details: [{ environment: 'env-1', locale: 'en-us' }], + publishDetails: { environments: ['production'], locales: ['en-us'] }, }, }); @@ -730,7 +693,7 @@ describe('ImportAssets', () => { await (importAssets as any).publish(); }); - it('should set correct UID from mapping', async () => { + it('should set correct UID from mapping and preserve the grouped payload', async () => { importAssets['assetsUidMap'] = { 'asset-1': 'mapped-asset-1' }; makeConcurrentCallStub.callsFake(async (options: any) => { @@ -738,35 +701,58 @@ describe('ImportAssets', () => { const result = serializeData({ apiData: { uid: 'asset-1', - publish_details: [{ environment: 'env-1', locale: 'en-us' }], + publishDetails: { environments: ['production'], locales: ['en-us'] }, }, }); expect(result.uid).to.equal('mapped-asset-1'); + // serializeData no longer flattens — it leaves the pre-grouped payload untouched. + expect(result.apiData.publishDetails).to.deep.equal({ environments: ['production'], locales: ['en-us'] }); }); await (importAssets as any).publish(); }); - it('should extract unique locales from publish_details', async () => { - makeConcurrentCallStub.callsFake(async (options: any) => { - const serializeData = options.apiParams.serializeData; - const result = serializeData({ - apiData: { - uid: 'asset-1', - publish_details: [ - { environment: 'env-1', locale: 'en-us' }, - { environment: 'env-1', locale: 'en-us' }, - { environment: 'env-1', locale: 'fr-fr' }, - ], - }, - }); + it('preserves env-locale pairing for a RAGGED asset (DX-9772) — no phantom pairs', async () => { + // production published only in en-us; preview published only in fr-fr. + importAssets['assetsUidMap'] = { 'asset-1': 'new-asset-1' }; + importAssets['environments'] = { 'env-1': { name: 'production' }, 'env-2': { name: 'preview' } }; + Object.defineProperty(FsUtility.prototype, 'readChunkFiles', { + get: sinon.stub().returns({ + next: sinon.stub().resolves([ + { + uid: 'asset-1', + title: 'ragged.jpg', + publish_details: [ + { environment: 'env-1', locale: 'en-us' }, + { environment: 'env-2', locale: 'fr-fr' } + ] + } + ]) + }), + configurable: true + }); - expect(result.apiData.locales).to.have.lengthOf(2); - expect(result.apiData.locales).to.include.members(['en-us', 'fr-fr']); + let apiContent: any[] = []; + makeConcurrentCallStub.callsFake(async (options: any) => { + apiContent = options.apiContent; }); await (importAssets as any).publish(); + + // Two separate publish calls, each a single-rectangle payload — never a cartesian. + expect(apiContent).to.have.lengthOf(2); + const payloads = apiContent.map((i) => i.publishDetails); + expect(payloads).to.deep.include.members([ + { environments: ['production'], locales: ['en-us'] }, + { environments: ['preview'], locales: ['fr-fr'] } + ]); + for (const p of payloads) { + const hasPhantom = + (p.environments.includes('production') && p.locales.includes('fr-fr')) || + (p.environments.includes('preview') && p.locales.includes('en-us')); + expect(hasPhantom, `phantom pair in ${JSON.stringify(p)}`).to.be.false; + } }); }); diff --git a/packages/contentstack-migrate-rte/package.json b/packages/contentstack-migrate-rte/package.json index 20f23e9bc..37ebe9b7f 100644 --- a/packages/contentstack-migrate-rte/package.json +++ b/packages/contentstack-migrate-rte/package.json @@ -83,4 +83,4 @@ "cm:entries:migrate-html-rte": "MGRTRTE" } } -} +} \ No newline at end of file diff --git a/packages/contentstack-migration/README.md b/packages/contentstack-migration/README.md index bacee3dc4..1755515ea 100644 --- a/packages/contentstack-migration/README.md +++ b/packages/contentstack-migration/README.md @@ -21,7 +21,7 @@ $ npm install -g @contentstack/cli-migration $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-migration/2.0.0-beta.16 darwin-arm64 node-v24.14.0 +@contentstack/cli-migration/1.12.6 darwin-arm64 node-v24.18.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-query-export/package.json b/packages/contentstack-query-export/package.json index 7d39d11b9..bdf8b2a5c 100644 --- a/packages/contentstack-query-export/package.json +++ b/packages/contentstack-query-export/package.json @@ -97,4 +97,4 @@ "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-query-export" } -} +} \ No newline at end of file diff --git a/packages/contentstack-seed/README.md b/packages/contentstack-seed/README.md index 63ed3233a..c783207c9 100644 --- a/packages/contentstack-seed/README.md +++ b/packages/contentstack-seed/README.md @@ -7,6 +7,18 @@ To import content to your stack, you can use either of the following: **Any GitHub repository**: You can also import content from another GitHub repository by passing `--repo` in `owner/repository` form (organization, user, or enterprise account). + +```sh-session +$ npm install -g @contentstack/cli-cm-seed +$ csdx COMMAND +running command... +$ csdx (--version) +@contentstack/cli-cm-seed/1.15.10 darwin-arm64 node-v24.18.0 +$ csdx --help [COMMAND] +USAGE + $ csdx COMMAND +... +``` ## Commands diff --git a/packages/contentstack-variants/package.json b/packages/contentstack-variants/package.json index c5fdd98fc..90fb970e9 100644 --- a/packages/contentstack-variants/package.json +++ b/packages/contentstack-variants/package.json @@ -38,4 +38,4 @@ "url": "git+https://github.com/contentstack/cli-plugins.git", "directory": "packages/contentstack-variants" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7a90ae7b..10639f084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: specifier: ~2.0.0-beta.12 version: 2.0.0-beta.12(@types/node@20.19.43) adm-zip: - specifier: ^0.5.18 - version: 0.5.18 + specifier: ^0.6.0 + version: 0.6.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -114,7 +114,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) shx: specifier: ^0.4.0 version: 0.4.0 @@ -169,7 +169,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) sinon: specifier: ^17.0.2 version: 17.0.2 @@ -248,7 +248,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) shx: specifier: ^0.4.0 version: 0.4.0 @@ -318,7 +318,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@18.19.130) + version: 4.23.30(@types/node@18.19.130) tmp: specifier: 0.2.7 version: 0.2.7 @@ -373,7 +373,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@22.20.1) + version: 4.23.30(@types/node@22.20.1) sinon: specifier: ^21.1.2 version: 21.1.2 @@ -396,10 +396,10 @@ importers: specifier: ~2.0.0-beta.12 version: 2.0.0-beta.12(@types/node@20.19.43) '@contentstack/delivery-sdk': - specifier: ^5.2.0 + specifier: ^5.4.0 version: 5.5.2 '@contentstack/management': - specifier: ^1.30.2 + specifier: ^1.30.3 version: 1.31.1 lodash: specifier: 4.18.1 @@ -470,7 +470,7 @@ importers: version: 18.0.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) prettier: specifier: ^3.8.3 version: 3.9.6 @@ -499,7 +499,7 @@ importers: specifier: ~2.0.0-beta.12 version: 2.0.0-beta.12(@types/node@18.19.130) '@contentstack/management': - specifier: ^1.30.2 + specifier: ^1.30.4 version: 1.31.1 cli-table3: specifier: ^0.6.5 @@ -570,7 +570,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.21 - version: 4.23.29(@types/node@18.19.130) + version: 4.23.30(@types/node@18.19.130) proxyquire: specifier: ^2.1.3 version: 2.1.3 @@ -631,7 +631,7 @@ importers: version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.9.3)) oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@22.20.1) + version: 4.23.30(@types/node@22.20.1) ts-jest: specifier: ^29.4.9 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.9.3)))(typescript@5.9.3) @@ -716,7 +716,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@18.19.130) + version: 4.23.30(@types/node@18.19.130) sinon: specifier: ^21.1.2 version: 21.1.2 @@ -756,9 +756,6 @@ importers: diff2html: specifier: ^3.4.56 version: 3.4.56 - git-diff: - specifier: ^2.0.7 - version: 2.0.7 moment: specifier: ^2.30.1 version: 2.30.1 @@ -804,7 +801,7 @@ importers: version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.9.3)) oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@22.20.1) + version: 4.23.30(@types/node@22.20.1) ts-jest: specifier: ^29.4.10 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.9.3)))(typescript@5.9.3) @@ -869,6 +866,9 @@ importers: '@contentstack/cli-config': specifier: ~2.0.0-beta.15 version: 2.0.0-beta.15(@types/node@22.20.1) + '@contentstack/cli-dev-dependencies': + specifier: ^1.3.1 + version: 1.3.1 '@oclif/plugin-help': specifier: ^6.2.49 version: 6.2.56 @@ -913,7 +913,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@22.20.1) + version: 4.23.30(@types/node@22.20.1) sinon: specifier: ^17.0.1 version: 17.0.2 @@ -980,7 +980,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) sinon: specifier: ^21.0.1 version: 21.1.2 @@ -1077,7 +1077,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.8.0 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) sinon: specifier: ^21.0.1 version: 21.1.2 @@ -1183,7 +1183,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@14.18.63) + version: 4.23.30(@types/node@14.18.63) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@14.18.63)(typescript@4.9.5) @@ -1274,7 +1274,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@14.18.63) + version: 4.23.30(@types/node@14.18.63) rewire: specifier: ^9.0.1 version: 9.0.1 @@ -1353,7 +1353,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@22.20.1) + version: 4.23.30(@types/node@22.20.1) querystring: specifier: ^0.2.1 version: 0.2.1 @@ -1429,7 +1429,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@14.18.63) + version: 4.23.30(@types/node@14.18.63) sinon: specifier: ^21.1.2 version: 21.1.2 @@ -1547,7 +1547,7 @@ importers: version: 15.1.0 oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@20.19.43) + version: 4.23.30(@types/node@20.19.43) sinon: specifier: ^17.0.2 version: 17.0.2 @@ -1620,7 +1620,7 @@ importers: version: 29.7.0(@types/node@18.19.130)(ts-node@8.10.2(typescript@5.9.3)) oclif: specifier: ^4.23.8 - version: 4.23.29(@types/node@18.19.130) + version: 4.23.30(@types/node@18.19.130) ts-jest: specifier: ^29.4.6 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@18.19.130)(ts-node@8.10.2(typescript@5.9.3)))(typescript@5.9.3) @@ -1698,12 +1698,12 @@ packages: resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cloudfront@3.1103.0': - resolution: {integrity: sha512-WjqHLohn8OnSpBrTTE2pen7w7BvWKQ1dmSzCTKjwZACqh0v6IbtK/B4pWWsnCBPtTEf+j+hm5DtySFf4N0JmHQ==} + '@aws-sdk/client-cloudfront@3.1104.0': + resolution: {integrity: sha512-dMUB4lCDuiceexvrEivTJAm8Pat6QuIorlLO3IwC2h8TRX/X460LDIosfduFXoIegv1HJ6XbrdVjmMBvO2v/xQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1103.0': - resolution: {integrity: sha512-FO7SB2vLhZRN3lBHVhMhRm3YKSHK8e917qjB8LMEEhU69cQ0Ahd/OMyezu+KqNANqEtyxfSnFVvYt81x6ZKGZA==} + '@aws-sdk/client-s3@3.1104.0': + resolution: {integrity: sha512-YCAVqUokR7KwpU8EZaciZhM7M3f2SirT8NJ/BgAy5qfycIuqRTXGXVbeJEdMg+Zk8BZscOHaiDoED2s/4SO8mA==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.977.6': @@ -2367,8 +2367,8 @@ packages: resolution: {integrity: sha512-uHDsRTj23vFqFo2j6b59aAbh49qxoCtQ+GxEyVxjYqSIbjHND4OM275pbfX5ATwfH+fU3Oj4+vn363RmgtG/nw==} engines: {node: '>=22.0.0'} - '@contentstack/cli-command@1.8.5': - resolution: {integrity: sha512-gwg04KOYZUDdr5vgjRfGSdIVIPZ/Qfnyv0tG+9ihpZwVl7JxsJ9Rpg8olAtw1oy+LqjYZzc6cx0uaCFO3c/Skg==} + '@contentstack/cli-command@1.8.6': + resolution: {integrity: sha512-GlkxqptxgzHPlpyS0yOKWTNDGNom4UpSlwqZgkWe5tWZJLqmml52u6GgzAvB6byDuzdwbaUzIO8+uwnfYuPmkQ==} engines: {node: '>=22.0.0'} '@contentstack/cli-command@2.0.0-beta.11': @@ -2379,13 +2379,16 @@ packages: resolution: {integrity: sha512-JXgLHc/KLrgzPY4lng11jL2xSTgT0Cl8/zKmVnSuj7in5HKCwWPNdyvbBNtk3C7QkxnR+3OpHqgOVMjVbbtyFg==} engines: {node: '>=22.0.0'} + '@contentstack/cli-dev-dependencies@1.3.1': + resolution: {integrity: sha512-RQuCGQxBdZ+aNhOMwt/VMpz/9AL2PwIFz7H9rUS6BzPOe6G4RjmzFLXi/gnyECbyLoIgyGGXTjlz8NQ0oapp7Q==} + '@contentstack/cli-launch@1.11.2': resolution: {integrity: sha512-CiGheBsLPq9Y7/KsO2z/Il1atvXILjtElUaLooX79XPb8N2PELEXHGaIlmtWB0oYUTWS+kNoC+cgHXsEAk6vzw==} engines: {node: '>=22.0.0'} hasBin: true - '@contentstack/cli-utilities@1.19.0': - resolution: {integrity: sha512-QkQ2KakgMLYt2oa7bov1RHb4N1YjJqGqGDkh3oiTfgN+c5FvqoyaRUrngQCjefat3GLd2MY2SOmgILcBCghTpw==} + '@contentstack/cli-utilities@1.19.1': + resolution: {integrity: sha512-gf7nWljHzsEIV+sOZra4pY3M5wojad2Iy3gPDFKl/F2VeH6GynGERVyz7hKjQgefBBitV6MGZKNIN+TVtR4gVA==} '@contentstack/cli-utilities@2.0.0-beta.12': resolution: {integrity: sha512-tE8NZNBpvDjLfU6EcYZd3DFpzBnCJUOcGRM/IH16QM3VWGYGuSaUoXmPVExh7Sm1eriA+IG9cQL0YCZDRCfdOg==} @@ -2928,8 +2931,8 @@ packages: resolution: {integrity: sha512-0LkCjFGRGY7AaZb5p8gDcSPNkFrn+vNLKvGe1f7j/Z6U9Uy1cV6R2eWVsjuZTGQ3RvBvjquFPwLy1jTgR5l87w==} engines: {node: '>=18.0.0'} - '@oclif/plugin-not-found@3.2.91': - resolution: {integrity: sha512-ICFrUeotKD3gsBOZR5mrgz2y1QDPM/C6hcu1i2tFjfW7AVnKINvsgANFou0ObLJGkslNS+6uX+1qcny37vJcyg==} + '@oclif/plugin-not-found@3.2.92': + resolution: {integrity: sha512-Fgamrm4o1lgH101mRHNwe6lw8VYZZ+E8frfqHq6hnSZME832/F0zCHv84MlOpw8ec9ub8dztg21OHi2jaMvE/g==} engines: {node: '>=18.0.0'} '@oclif/plugin-warn-if-update-available@3.1.71': @@ -3865,6 +3868,10 @@ packages: resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} engines: {node: '>=12.0'} + adm-zip@0.6.0: + resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + engines: {node: '>=14.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4817,10 +4824,6 @@ packages: resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==} engines: {node: '>=12'} - diff@3.5.1: - resolution: {integrity: sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==} - engines: {node: '>=0.3.1'} - diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -4891,8 +4894,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.401: - resolution: {integrity: sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==} + electron-to-chromium@1.5.402: + resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} elegant-spinner@1.0.1: resolution: {integrity: sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ==} @@ -5553,10 +5556,6 @@ packages: get-tsconfig@4.14.1: resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} - git-diff@2.0.7: - resolution: {integrity: sha512-/+vyWaKNUJLcVT+tm5Hsly2xDcIs49EkZstxqW7ap1ZiZ0BECviLK1iv9/f4cGhlKBokeAf61QkTDnL88H+Uhg==} - engines: {node: '>= 4.8.0'} - git-raw-commits@5.0.1: resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} engines: {node: '>=18'} @@ -6560,10 +6559,6 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} - loglevel@1.9.2: - resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} - engines: {node: '>= 0.6.0'} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -6850,8 +6845,8 @@ packages: resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} engines: {node: '>=8'} - node-releases@2.0.52: - resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} nopt@1.0.10: @@ -6942,8 +6937,8 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - oclif@4.23.29: - resolution: {integrity: sha512-pUekmMh+wwIbFq3xX1EfkgZ7RuYuuSbdhJiB+20zqSfGE8Tt9T+K9br1/vmsaIOxfDo0Dw3jxLBk4JZOMFEMeQ==} + oclif@4.23.30: + resolution: {integrity: sha512-x8sMRv606XqZAWEfGh981PP2JM4G2CKt4/z2KDntoNHljD8slfJ5sHaDFUQya9w6W8xOJDMFF3UaLAFFYHo13w==} engines: {node: '>=18.0.0'} hasBin: true @@ -7670,19 +7665,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shelljs.exec@1.1.8: - resolution: {integrity: sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==} - engines: {node: '>= 4.0.0'} - shelljs@0.10.0: resolution: {integrity: sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==} engines: {node: '>=18'} - shelljs@0.8.5: - resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} - engines: {node: '>=4'} - hasBin: true - shelljs@0.9.2: resolution: {integrity: sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==} engines: {node: '>=18'} @@ -8661,7 +8647,7 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-cloudfront@3.1103.0': + '@aws-sdk/client-cloudfront@3.1104.0': dependencies: '@aws-sdk/core': 3.977.6 '@aws-sdk/credential-provider-node': 3.972.78 @@ -8672,7 +8658,7 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1103.0': + '@aws-sdk/client-s3@3.1104.0': dependencies: '@aws-sdk/checksums': 3.1000.26 '@aws-sdk/core': 3.977.6 @@ -9587,9 +9573,9 @@ snapshots: - debug - supports-color - '@contentstack/cli-command@1.8.5(@types/node@20.19.43)': + '@contentstack/cli-command@1.8.6(@types/node@20.19.43)': dependencies: - '@contentstack/cli-utilities': 1.19.0(@types/node@20.19.43) + '@contentstack/cli-utilities': 1.19.1(@types/node@20.19.43) '@oclif/core': 4.13.3 contentstack: 3.27.1 transitivePeerDependencies: @@ -9669,11 +9655,20 @@ snapshots: - debug - supports-color + '@contentstack/cli-dev-dependencies@1.3.1': + dependencies: + '@oclif/core': 4.13.3 + '@oclif/test': 4.1.21(@oclif/core@4.13.3) + fancy-test: 2.0.42 + lodash: 4.18.1 + transitivePeerDependencies: + - supports-color + '@contentstack/cli-launch@1.11.2(@types/node@20.19.43)(tslib@2.8.1)(typescript@5.9.3)': dependencies: '@apollo/client': 3.14.1(graphql@16.14.2) - '@contentstack/cli-command': 1.8.5(@types/node@20.19.43) - '@contentstack/cli-utilities': 1.19.0(@types/node@20.19.43) + '@contentstack/cli-command': 1.8.6(@types/node@20.19.43) + '@contentstack/cli-utilities': 1.19.1(@types/node@20.19.43) '@oclif/core': 4.13.3 '@oclif/plugin-help': 6.2.56 '@rollup/plugin-commonjs': 28.0.9(rollup@4.62.4) @@ -9707,7 +9702,7 @@ snapshots: - tslib - typescript - '@contentstack/cli-utilities@1.19.0(@types/node@20.19.43)': + '@contentstack/cli-utilities@1.19.1(@types/node@20.19.43)': dependencies: '@contentstack/management': 1.30.4(debug@4.4.3) '@contentstack/marketplace-sdk': 1.5.4(debug@4.4.3) @@ -11126,7 +11121,7 @@ snapshots: dependencies: '@oclif/core': 4.13.3 - '@oclif/plugin-not-found@3.2.91(@types/node@14.18.63)': + '@oclif/plugin-not-found@3.2.92(@types/node@14.18.63)': dependencies: '@inquirer/prompts': 7.10.1(@types/node@14.18.63) '@oclif/core': 4.13.3 @@ -11135,7 +11130,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@oclif/plugin-not-found@3.2.91(@types/node@18.19.130)': + '@oclif/plugin-not-found@3.2.92(@types/node@18.19.130)': dependencies: '@inquirer/prompts': 7.10.1(@types/node@18.19.130) '@oclif/core': 4.13.3 @@ -11144,7 +11139,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@oclif/plugin-not-found@3.2.91(@types/node@20.19.43)': + '@oclif/plugin-not-found@3.2.92(@types/node@20.19.43)': dependencies: '@inquirer/prompts': 7.10.1(@types/node@20.19.43) '@oclif/core': 4.13.3 @@ -11153,7 +11148,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@oclif/plugin-not-found@3.2.91(@types/node@22.20.1)': + '@oclif/plugin-not-found@3.2.92(@types/node@22.20.1)': dependencies: '@inquirer/prompts': 7.10.1(@types/node@22.20.1) '@oclif/core': 4.13.3 @@ -12486,6 +12481,8 @@ snapshots: adm-zip@0.5.18: {} + adm-zip@0.6.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -12863,8 +12860,8 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.12 caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.401 - node-releases: 2.0.52 + electron-to-chromium: 1.5.402 + node-releases: 2.0.53 update-browserslist-db: 1.2.3(browserslist@4.28.7) bs-logger@0.2.6: @@ -13538,8 +13535,6 @@ snapshots: optionalDependencies: highlight.js: 11.11.1 - diff@3.5.1: {} - diff@4.0.4: {} diff@5.2.2: {} @@ -13608,7 +13603,7 @@ snapshots: dependencies: jake: 10.9.4 - electron-to-chromium@1.5.401: {} + electron-to-chromium@1.5.402: {} elegant-spinner@1.0.1: {} @@ -14745,14 +14740,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - git-diff@2.0.7: - dependencies: - chalk: 2.4.2 - diff: 3.5.1 - loglevel: 1.9.2 - shelljs: 0.8.5 - shelljs.exec: 1.1.8 - git-raw-commits@5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): dependencies: '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) @@ -16179,8 +16166,6 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 - loglevel@1.9.2: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -16443,7 +16428,7 @@ snapshots: dependencies: process-on-spawn: 1.1.0 - node-releases@2.0.52: {} + node-releases@2.0.53: {} nopt@1.0.10: dependencies: @@ -16593,16 +16578,16 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 - oclif@4.23.29(@types/node@14.18.63): + oclif@4.23.30(@types/node@14.18.63): dependencies: - '@aws-sdk/client-cloudfront': 3.1103.0 - '@aws-sdk/client-s3': 3.1103.0 + '@aws-sdk/client-cloudfront': 3.1104.0 + '@aws-sdk/client-s3': 3.1104.0 '@inquirer/confirm': 3.2.0 '@inquirer/input': 2.3.0 '@inquirer/select': 2.5.0 '@oclif/core': 4.13.3 '@oclif/plugin-help': 6.2.56 - '@oclif/plugin-not-found': 3.2.91(@types/node@14.18.63) + '@oclif/plugin-not-found': 3.2.92(@types/node@14.18.63) '@oclif/plugin-warn-if-update-available': 3.1.71 ansis: 3.17.0 async-retry: 1.3.3 @@ -16622,16 +16607,16 @@ snapshots: - '@types/node' - supports-color - oclif@4.23.29(@types/node@18.19.130): + oclif@4.23.30(@types/node@18.19.130): dependencies: - '@aws-sdk/client-cloudfront': 3.1103.0 - '@aws-sdk/client-s3': 3.1103.0 + '@aws-sdk/client-cloudfront': 3.1104.0 + '@aws-sdk/client-s3': 3.1104.0 '@inquirer/confirm': 3.2.0 '@inquirer/input': 2.3.0 '@inquirer/select': 2.5.0 '@oclif/core': 4.13.3 '@oclif/plugin-help': 6.2.56 - '@oclif/plugin-not-found': 3.2.91(@types/node@18.19.130) + '@oclif/plugin-not-found': 3.2.92(@types/node@18.19.130) '@oclif/plugin-warn-if-update-available': 3.1.71 ansis: 3.17.0 async-retry: 1.3.3 @@ -16651,16 +16636,16 @@ snapshots: - '@types/node' - supports-color - oclif@4.23.29(@types/node@20.19.43): + oclif@4.23.30(@types/node@20.19.43): dependencies: - '@aws-sdk/client-cloudfront': 3.1103.0 - '@aws-sdk/client-s3': 3.1103.0 + '@aws-sdk/client-cloudfront': 3.1104.0 + '@aws-sdk/client-s3': 3.1104.0 '@inquirer/confirm': 3.2.0 '@inquirer/input': 2.3.0 '@inquirer/select': 2.5.0 '@oclif/core': 4.13.3 '@oclif/plugin-help': 6.2.56 - '@oclif/plugin-not-found': 3.2.91(@types/node@20.19.43) + '@oclif/plugin-not-found': 3.2.92(@types/node@20.19.43) '@oclif/plugin-warn-if-update-available': 3.1.71 ansis: 3.17.0 async-retry: 1.3.3 @@ -16680,16 +16665,16 @@ snapshots: - '@types/node' - supports-color - oclif@4.23.29(@types/node@22.20.1): + oclif@4.23.30(@types/node@22.20.1): dependencies: - '@aws-sdk/client-cloudfront': 3.1103.0 - '@aws-sdk/client-s3': 3.1103.0 + '@aws-sdk/client-cloudfront': 3.1104.0 + '@aws-sdk/client-s3': 3.1104.0 '@inquirer/confirm': 3.2.0 '@inquirer/input': 2.3.0 '@inquirer/select': 2.5.0 '@oclif/core': 4.13.3 '@oclif/plugin-help': 6.2.56 - '@oclif/plugin-not-found': 3.2.91(@types/node@22.20.1) + '@oclif/plugin-not-found': 3.2.92(@types/node@22.20.1) '@oclif/plugin-warn-if-update-available': 3.1.71 ansis: 3.17.0 async-retry: 1.3.3 @@ -17481,19 +17466,11 @@ snapshots: shebang-regex@3.0.0: {} - shelljs.exec@1.1.8: {} - shelljs@0.10.0: dependencies: execa: 5.1.1 fast-glob: 3.3.3 - shelljs@0.8.5: - dependencies: - glob: 7.2.3 - interpret: 1.4.0 - rechoir: 0.6.2 - shelljs@0.9.2: dependencies: execa: 1.0.0