diff --git a/.talismanrc b/.talismanrc index 67800df0a..90ec9724b 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,19 @@ fileignoreconfig: + - filename: pnpm-lock.yaml checksum: c3bcd614567a430c43ef8c3aa5a4ea2e006486137ba8397d58f01de89b858a1e -version: '1.0' +- filename: packages/contentstack-seed/tests/importer.test.ts + checksum: cf452b6bc7d3cfcef493ee64e1f5a4e97ac841292ff8ba3515375fc4e8ed049c +- filename: packages/contentstack-variants/test/unit/import/variant-entries.test.ts + checksum: 6aca150cf9656d27acb1811f478d298668ec28f610b2d4820a6d5115612d52c0 +- filename: packages/contentstack-export/src/config/index.ts + checksum: 6fa4bba2174bbf33f5611098f49a02bf2fc789f59634e99be58de7e370f5fcd3 +- filename: packages/contentstack-branches/test/unit/helpers/stub-auth.ts + checksum: 8cafd5994d3ec13ba9af74c80b330bfd14721ea4e0359b456598964a6c2913ce +- filename: packages/contentstack-seed/tests/contentstack.test.ts + checksum: 04196568bf29f4693e968079867e87d96219b022bfee0f4697dce1b9bc161ac3 +- filename: packages/contentstack-export/src/utils/export-config-handler.ts + checksum: 362c1042a70958b52e73a7a3096152cf0259c55095c8b2c6f308b8f5fb90614e +- filename: packages/contentstack-import/src/utils/import-config-handler.ts + checksum: f310186b5ea1fc756bd200dbe01967bc07e5490dc5ed17018f0ca4b35f0fd86f + version: '1.0' diff --git a/packages/contentstack-bulk-publish/.mocharc.json b/packages/contentstack-bulk-publish/.mocharc.json index 50e4a6804..3d76d7307 100644 --- a/packages/contentstack-bulk-publish/.mocharc.json +++ b/packages/contentstack-bulk-publish/.mocharc.json @@ -5,7 +5,8 @@ "test/unit/commands/assets/unpublish.test.js", "test/unit/commands/bulk-publish/cross-publish.test.js", "test/unit/commands/entries/publish.test.js", - "test/unit/commands/entries/unpublish.test.js" + "test/unit/commands/entries/unpublish.test.js", + "test/unit/util/asset-scan.test.js" ], "reporter": "dot", "timeout": 60000, diff --git a/packages/contentstack-bulk-publish/src/commands/cm/assets/publish.js b/packages/contentstack-bulk-publish/src/commands/cm/assets/publish.js index a032678c0..b121906c9 100644 --- a/packages/contentstack-bulk-publish/src/commands/cm/assets/publish.js +++ b/packages/contentstack-bulk-publish/src/commands/cm/assets/publish.js @@ -15,10 +15,12 @@ class AssetsPublishCommand extends Command { assetsFlags.folderUid = assetsFlags['folder-uid'] || assetsFlags.folderUid; assetsFlags.bulkPublish = assetsFlags['bulk-publish'] || assetsFlags.bulkPublish; assetsFlags.apiVersion = assetsFlags['api-version'] || '3'; // setting default value for apiVersion + assetsFlags.backupDir = assetsFlags['backup-dir'] || assetsFlags.backupDir; delete assetsFlags['api-version']; delete assetsFlags['retry-failed']; delete assetsFlags['folder-uid']; delete assetsFlags['bulk-publish']; + delete assetsFlags['backup-dir']; let updatedFlags; try { @@ -111,12 +113,18 @@ class AssetsPublishCommand extends Command { } } - validate({ environments, retryFailed, locales, 'source-env': sourceEnv, 'delivery-token': deliveryToken }) { + validate({ environments, retryFailed, locales, backupDir, 'source-env': sourceEnv, 'delivery-token': deliveryToken }) { let missing = []; if (retryFailed) { return true; } + // In backup-dir mode, environments and locales are derived per-asset from the + // backup publish_details, so they are not required on the command line. + if (backupDir) { + return true; + } + if (sourceEnv && !deliveryToken) { this.error('Specify the source environment delivery token. Run --help for more details.', { exit: 2 }); } @@ -181,6 +189,11 @@ AssetsPublishCommand.flags = { '(optional) The UID of the Assets’ folder from which the assets need to be published. The default value is cs_root.', exclusive: ['source-env'], }), + 'backup-dir': flags.string({ + description: + '(optional) Path to the import backup directory. When set, each imported asset is published only to the environments and locales it was published to in the source stack (read from the backup’s publish details and asset UID mapping), with asset-scan gating applied. Intended for the post-import publish flow.', + exclusive: ['source-env', 'folder-uid', 'environments', 'locales'], + }), 'bulk-publish': flags.string({ description: 'Set this flag to use Contentstack’s Bulk Publish APIs. It is true, by default.', default: 'true', @@ -259,11 +272,14 @@ AssetsPublishCommand.examples = [ '', 'Using --stack-api-key flag', 'csdx cm:assets:publish --environments [ENVIRONMENT 1] [ENVIRONMENT 2] --locales [LOCALE] --stack-api-key [STACK API KEY]', + '', + 'Using --backup-dir flag (publish imported assets to their original environments after asset scanning)', + 'csdx cm:assets:publish --backup-dir [PATH TO IMPORT BACKUP DIR] --stack-api-key [STACK API KEY]', ]; AssetsPublishCommand.aliases = ['cm:bulk-publish:assets']; AssetsPublishCommand.usage = - 'cm:assets:publish [-a ] [--retry-failed ] [-e ] [--folder-uid ] [--bulk-publish ] [-c ] [-y] [--locales ] [--branch ] [--delivery-token ] [--source-env ]'; + 'cm:assets:publish [-a ] [--retry-failed ] [-e ] [--folder-uid ] [--backup-dir ] [--bulk-publish ] [-c ] [-y] [--locales ] [--branch ] [--delivery-token ] [--source-env ]'; module.exports = AssetsPublishCommand; diff --git a/packages/contentstack-bulk-publish/src/consumer/publish.js b/packages/contentstack-bulk-publish/src/consumer/publish.js index f466b250c..cea6392fd 100644 --- a/packages/contentstack-bulk-publish/src/consumer/publish.js +++ b/packages/contentstack-bulk-publish/src/consumer/publish.js @@ -61,12 +61,12 @@ function displayEntriesDetails(sanitizedData, action, mapping = []) { function displayAssetsDetails(sanitizedData, action, mapping) { if (action === 'bulk_publish') { sanitizedData.forEach((asset) => { - asset?.publish_details.forEach((pd) => { + asset?.publish_details?.forEach((pd) => { if (Object.keys(mapping).includes(pd.environment)) { console.log( chalk.green( `Asset UID: '${asset.uid}'${pd.version ? `, Version: '${pd.version}'` : ''}${ - asset.locale ? `, Locale: '${asset.locale}'` : '' + asset.locale ? `, Locale: '${asset.locale}'` : ''} }, Environment: ${pd.environment}`, ), ); diff --git a/packages/contentstack-bulk-publish/src/producer/publish-assets.js b/packages/contentstack-bulk-publish/src/producer/publish-assets.js index a9afbdd7c..f35f2d751 100644 --- a/packages/contentstack-bulk-publish/src/producer/publish-assets.js +++ b/packages/contentstack-bulk-publish/src/producer/publish-assets.js @@ -1,7 +1,10 @@ /* eslint-disable no-console */ /* eslint-disable new-cap */ /* eslint-disable camelcase */ -const { cliux } = require('@contentstack/cli-utilities'); +const path = require('path'); +const { existsSync } = require('fs'); +const chalk = require('chalk'); +const { cliux, FsUtility } = require('@contentstack/cli-utilities'); const { getQueue } = require('../util/queue'); const { performBulkPublish, publishAsset, initializeLogger } = require('../consumer/publish'); const retryFailedLogs = require('../util/retryfailed'); @@ -9,14 +12,27 @@ const { validateFile } = require('../util/fs'); const { isEmpty } = require('../util'); const { fetchBulkPublishLimit } = require('../util/common-utility'); const { generateBulkPublishStatusUrl } = require('../util/generate-bulk-publish-url'); +const { resolveInQueueAssets, fetchScanStatusBatch, ASSET_SCAN_STATUS } = require('../util/asset-scan'); const queue = getQueue(); let logFileName; let bulkPublishSet = []; +let pendingAssetsForRetry = []; +let scanSummary = { clean: 0, quarantined: 0, inQueue: 0, noStatus: 0 }; let filePath; /* eslint-disable no-param-reassign */ +function printScanSummary({ clean, noStatus, inQueue, quarantined }) { + const total = clean + noStatus + inQueue + quarantined; + if (total === 0) return; + console.log(chalk.bold(`\nAsset scan summary (${total} total):`)); + console.log(chalk.green(` ✓ Clean (publishing): ${clean}`)); + if (noStatus > 0) console.log(chalk.green(` ✓ No scan status (publishing): ${noStatus}`)); + if (inQueue > 0) console.log(chalk.yellow(` ⧖ In queue (retrying): ${inQueue}`)); + if (quarantined > 0) console.log(chalk.red(` ✗ Quarantined (skipped): ${quarantined}`)); +} + async function getAssets(stack, folder, bulkPublish, environments, locale, apiVersion, bulkPublishLimit, skip = 0) { return new Promise((resolve, reject) => { let queryParams = { @@ -25,6 +41,7 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe include_count: true, include_folders: true, include_publish_details: true, + include_asset_scan_status: true, }; stack .asset() @@ -34,7 +51,8 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe if (assetResponse && assetResponse.items.length > 0) { skip += assetResponse.items.length; let assets = assetResponse.items; - for (let index = 0; index < assetResponse.items.length; index++) { + + for (let index = 0; index < assets.length; index++) { if (assets[index].is_dir === true) { await getAssets( stack, @@ -48,6 +66,35 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe ); continue; } + + const scanStatus = assets[index]._asset_scan_status; + + // Quarantined assets are skipped permanently + if (scanStatus === ASSET_SCAN_STATUS.QUARANTINE) { + scanSummary.quarantined++; + console.log(chalk.yellow(`Skipped (quarantined): Asset UID '${assets[index].uid}'`)); + continue; + } + + // In-queue assets are deferred for retry after all pages are processed + if (scanStatus === ASSET_SCAN_STATUS.IN_QUEUE) { + scanSummary.inQueue++; + pendingAssetsForRetry.push({ + uid: assets[index].uid, + locale, + publish_details: assets[index].publish_details || [], + environments, + }); + continue; + } + + // Ready (clean) or no scan status — enqueue for publish + if (scanStatus === ASSET_SCAN_STATUS.READY) { + scanSummary.clean++; + } else { + scanSummary.noStatus++; + } + if (bulkPublish) { if (bulkPublishSet.length < bulkPublishLimit) { bulkPublishSet.push({ @@ -67,22 +114,6 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe }); bulkPublishSet = []; } - - if ( - assetResponse.items.length - 1 === index && - bulkPublishSet.length > 0 && - bulkPublishSet.length < bulkPublishLimit - ) { - await queue.Enqueue({ - assets: bulkPublishSet, - Type: 'asset', - environments: environments, - locale, - stack: stack, - apiVersion, - }); - bulkPublishSet = []; - } } else { await queue.Enqueue({ assetUid: assets[index].uid, @@ -94,6 +125,23 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe }); } } + + // Flush any partial bulk batch at the end of the page. + // Done outside the for-loop so it fires correctly even when some assets + // were skipped (quarantined/in-queue) and the last non-skipped asset is + // not at the final array index. + if (bulkPublish && bulkPublishSet.length > 0) { + await queue.Enqueue({ + assets: bulkPublishSet, + Type: 'asset', + environments: environments, + locale, + stack: stack, + apiVersion, + }); + bulkPublishSet = []; + } + if (skip === assetResponse.count) { return resolve(true); } @@ -109,6 +157,291 @@ async function getAssets(stack, folder, bulkPublish, environments, locale, apiVe }); } +/** + * After all pages/locales are scanned, retry any assets that were in-queue. + * Takes pendingItems explicitly — does not read from module-level state. + * Uses incremental backoff (see asset-scan.js SCAN_RETRY config). + */ +async function processPendingAssets(pendingItems, stack, bulkPublish, environments, apiVersion, bulkPublishLimit) { + if (pendingItems.length === 0) return; + + // Deduplicate UIDs across locales — scan status is per-asset, not per-locale. + // Resolving once avoids redundant retry loops for multi-locale runs. + const allUids = [...new Set(pendingItems.map((a) => a.uid))]; + const resolvedUids = await resolveInQueueAssets(stack, allUids); + + if (resolvedUids.length === 0) { + console.log(chalk.yellow('No in-queue assets resolved after retries.')); + return; + } + + const resolvedSet = new Set(resolvedUids); + + // Group resolved items by locale for correct enqueue context + const byLocale = {}; + for (const item of pendingItems) { + if (!resolvedSet.has(item.uid)) continue; + if (!byLocale[item.locale]) byLocale[item.locale] = []; + byLocale[item.locale].push(item); + } + + for (const locale of Object.keys(byLocale)) { + const resolvedItems = byLocale[locale]; + + if (bulkPublish) { + let batchSet = []; + for (const item of resolvedItems) { + batchSet.push({ uid: item.uid, locale, publish_details: item.publish_details }); + if (batchSet.length === bulkPublishLimit) { + await queue.Enqueue({ + assets: batchSet, + Type: 'asset', + environments, + locale, + stack, + apiVersion, + }); + batchSet = []; + } + } + if (batchSet.length > 0) { + await queue.Enqueue({ + assets: batchSet, + Type: 'asset', + environments, + locale, + stack, + apiVersion, + }); + } + } else { + for (const item of resolvedItems) { + await queue.Enqueue({ + assetUid: item.uid, + publish_details: item.publish_details, + environments, + Type: 'asset', + locale, + stack, + }); + } + } + } +} + +/** + * Publish assets from an import backup directory (post-import flow). + * + * Unlike getAssets (live folder scan), this drives publishing from the backup: + * each imported asset is published ONLY to the environments/locales it was + * published to in the source stack (from its publish_details), remapped to the + * target stack. Scan-status gating is applied to the target asset UIDs. + * + * Mirrors the publish_details/env-name resolution of contentstack-import's + * assets `publish()` (the bulk publish API resolves environment NAMES against + * the target stack, and import preserves env names, so source name == target + * name), and adds the clean/quarantined/in-queue scan gating that import skips. + * + * Source of truth split: + * - publish_details + environments come from the BACKUP (post-import flow): an + * asset's target environments are its source publish_details, gated by the + * environment uid-mapping (only environments actually imported into the target + * are publishable) — avoids doomed publish calls to envs never created there. + * - scan status comes from the LIVE target API (it is a runtime property of the + * freshly-imported assets and cannot exist in the backup). + * + * Streaming: asset chunks are processed and released one at a time and scan-gated + * per chunk, so memory does not scale with total asset count. The only structures + * retained across chunks are bounded (partial publish batches + the in-queue + * subset). The single in-memory floor is the asset uid-mapping file itself (same + * as import's publish()); for very large stacks raise Node's --max-old-space-size. + */ +async function getAssetsFromBackup(stack, backupDir, bulkPublish, apiVersion, bulkPublishLimit) { + const assetsPath = path.join(backupDir, 'assets'); + const assetsIndexPath = path.join(assetsPath, 'assets.json'); + const assetUidMapperPath = path.join(backupDir, 'mapper', 'assets', 'uid-mapping.json'); + const envUidMapperPath = path.join(backupDir, 'mapper', 'environments', 'uid-mapping.json'); + const environmentsPath = path.join(backupDir, 'environments', 'environments.json'); + + // A backup with no assets is a legitimate outcome of a successful import that + // had 0 assets (assets.json is never produced) — exit cleanly, not as a crash. + // A genuinely wrong --backup-dir surfaces via the uid-mapping/environments guards below. + if (!existsSync(assetsPath) || !existsSync(assetsIndexPath)) { + console.log(chalk.yellow('No assets found in backup — nothing to publish.')); + return; + } + if (!existsSync(assetUidMapperPath)) { + throw new Error( + `Asset UID mapping not found at '${assetUidMapperPath}'. Run import against this data dir before publishing.`, + ); + } + if (!existsSync(environmentsPath)) { + throw new Error(`Environments not found at '${environmentsPath}'. Cannot resolve target environments.`); + } + + const fsUtil = new FsUtility({ basePath: assetsPath, indexFileName: 'assets.json' }); + const assetUidMap = fsUtil.readFile(assetUidMapperPath, true) || {}; + // environments.json: { [sourceEnvUid]: { name, ... } } — source env definitions. + const environments = fsUtil.readFile(environmentsPath, true) || {}; + // uid-mapping.json: { [sourceEnvUid]: targetEnvUid } — only environments actually + // imported into the target. Used as the "is publishable" gate. Optional: older + // backups (or runs with no imported environments) may not have it. + const envUidMapping = existsSync(envUidMapperPath) ? fsUtil.readFile(envUidMapperPath, true) || {} : null; + if (!envUidMapping) { + console.log( + chalk.yellow( + `Environment UID mapping not found at '${envUidMapperPath}'. Falling back to environment names from ` + + `environments.json — ensure the target stack has environments with matching names.`, + ), + ); + } + const isEnvImported = (sourceEnvUid) => + !envUidMapping || Object.prototype.hasOwnProperty.call(envUidMapping, sourceEnvUid); + + // Resolve an asset's deduped, env-gated target (envName, locale) pairs from its + // publish_details. Env name comes from the backup; only environments actually + // imported into the target (per the env uid-mapping) are publishable. + const resolvePairs = (asset) => { + const seen = new Set(); + const pairs = []; + for (const pd of asset.publish_details) { + const env = environments[pd.environment]; + if (!env || !env.name) continue; // env not in the data dir — cannot resolve a name + if (!isEnvImported(pd.environment)) continue; // env not imported into target — skip + const key = `${env.name}||${pd.locale}`; + if (seen.has(key)) continue; + seen.add(key); + pairs.push({ envName: env.name, locale: pd.locale }); + } + return pairs; + }; + + // Bounded cross-chunk state only — nothing scales with total asset count: + // - `buffers`: partial publish batches, capped at envCount x localeCount x bulkPublishLimit. + // - `pending`: the in-queue (scanning) subset awaiting retry. + // The full asset universe is never held in memory; chunks are processed and + // released one at a time (same streaming shape as contentstack-import's publish()). + const buffers = new Map(); // "envName||locale" -> { envName, locale, uids: [] } + const pending = []; // { targetUid, pairs } for assets whose scan is still in queue + let skippedNoUidMapping = 0; // source asset was not imported (no asset uid mapping) + let skippedNoMappableEnv = 0; // asset has publish details, but none of its envs were imported + let publishableAssets = 0; // assets enqueued for publish (across all env/locale pairs) + + const enqueueBatch = async (envName, locale, uids) => { + if (uids.length === 0) return; + if (bulkPublish) { + const assets = uids.map((uid) => ({ uid, locale })); + await queue.Enqueue({ assets, Type: 'asset', environments: [envName], locale, stack, apiVersion }); + } else { + for (const uid of uids) { + await queue.Enqueue({ assetUid: uid, environments: [envName], Type: 'asset', locale, stack }); + } + } + }; + + // Add a publishable asset to its (env, locale) buffers, flushing any that fill up. + // Grouping by the exact pair keeps the bulk API from publishing an asset to a + // combo it was not published to in source. + const bufferAsset = async (targetUid, pairs) => { + publishableAssets++; + for (const { envName, locale } of pairs) { + const key = `${envName}||${locale}`; + let buf = buffers.get(key); + if (!buf) { + buf = { envName, locale, uids: [] }; + buffers.set(key, buf); + } + buf.uids.push(targetUid); + if (buf.uids.length >= bulkPublishLimit) { + await enqueueBatch(envName, locale, buf.uids); + buf.uids = []; + } + } + }; + + const indexer = fsUtil.indexFileContent; + + // NOTE: one readChunkFiles.next() call per index entry — the iteration count must + // equal the number of chunk files (same contract as contentstack-import's publish()). + for (const _index in indexer) { + const chunk = await fsUtil.readChunkFiles.next(); + const assetsArr = Object.values(chunk || {}); + + // Resolve this chunk's assets to publish targets (bounded by chunk size). + const resolved = []; + for (const asset of assetsArr) { + if (!asset || !Array.isArray(asset.publish_details) || asset.publish_details.length === 0) { + continue; + } + const targetUid = assetUidMap[asset.uid]; + if (!targetUid) { + skippedNoUidMapping++; + continue; + } + const pairs = resolvePairs(asset); + if (pairs.length === 0) { + skippedNoMappableEnv++; + continue; + } + resolved.push({ targetUid, pairs }); + } + if (resolved.length === 0) continue; + + // Scan status is a target-stack property of the freshly-imported assets, so it + // is fetched live (one batched read per chunk) — it is not in the backup. + const statusMap = await fetchScanStatusBatch( + stack, + resolved.map((r) => r.targetUid), + ); + + for (const { targetUid, pairs } of resolved) { + const status = statusMap.get(targetUid); + if (status === ASSET_SCAN_STATUS.QUARANTINE) { + scanSummary.quarantined++; + console.log(chalk.yellow(`Skipped (quarantined): Asset UID '${targetUid}'`)); + } else if (status === ASSET_SCAN_STATUS.IN_QUEUE) { + scanSummary.inQueue++; + pending.push({ targetUid, pairs }); + } else { + if (status === ASSET_SCAN_STATUS.READY) scanSummary.clean++; + else scanSummary.noStatus++; + await bufferAsset(targetUid, pairs); + } + } + } + + // Resolve in-queue assets once (incremental backoff); publish those that turn clean. + if (pending.length > 0) { + const resolvedUids = await resolveInQueueAssets( + stack, + pending.map((p) => p.targetUid), + ); + const resolvedSet = new Set(resolvedUids); + for (const { targetUid, pairs } of pending) { + if (resolvedSet.has(targetUid)) await bufferAsset(targetUid, pairs); + } + } + + // Flush remaining partial (env, locale) batches. + for (const { envName, locale, uids } of buffers.values()) { + await enqueueBatch(envName, locale, uids); + } + + if (skippedNoUidMapping > 0) { + console.log(chalk.yellow(`Skipped ${skippedNoUidMapping} asset(s): no UID mapping (not imported into target).`)); + } + if (skippedNoMappableEnv > 0) { + console.log( + chalk.yellow( + `Skipped ${skippedNoMappableEnv} asset(s): none of their published environments were imported into the target.`, + ), + ); + } + if (publishableAssets === 0) { + console.log(chalk.yellow('No publishable assets found in backup (no mapped assets with publishable environments).')); + } +} + function setConfig(conf, bp) { if (bp) { queue.consumer = performBulkPublish; @@ -120,10 +453,15 @@ function setConfig(conf, bp) { config = conf; queue.config = conf; filePath = initializeLogger(logFileName); + pendingAssetsForRetry = []; + scanSummary = { clean: 0, quarantined: 0, inQueue: 0, noStatus: 0 }; } -async function start({ retryFailed, bulkPublish, environments, folderUid, locales, apiVersion }, stack, config) { +async function start({ retryFailed, bulkPublish, environments, folderUid, locales, apiVersion, backupDir }, stack, config) { process.on('beforeExit', async () => { + // Print the scan summary here (not inline after enqueueing): + printScanSummary(scanSummary); + const isErrorLogEmpty = await isEmpty(`${filePath}.error`); const isSuccessLogEmpty = await isEmpty(`${filePath}.success`); if (!isErrorLogEmpty) { @@ -131,7 +469,7 @@ async function start({ retryFailed, bulkPublish, environments, folderUid, locale } else if (!isSuccessLogEmpty) { console.log(`The success log for this session is stored at ${filePath}.success`); } - + // Generate and display the bulk publish status link if (bulkPublish && stack && config) { const statusUrl = generateBulkPublishStatusUrl(stack, config); @@ -142,11 +480,12 @@ async function start({ retryFailed, bulkPublish, environments, folderUid, locale process.stdout.write('\n'); } } - + process.exit(0); }); if (retryFailed) { + console.log(chalk.yellow('Note: --retry-failed replays from log and skips asset scan status checks.')); if (!validateFile(retryFailed, ['publish-assets', 'bulk-publish-assets'])) { return false; } @@ -159,17 +498,31 @@ async function start({ retryFailed, bulkPublish, environments, folderUid, locale } else { await retryFailedLogs(retryFailed, { assetQueue: queue }, 'publish'); } + } else if (backupDir) { + // Post-import flow: publish each imported asset only to its original + // environments/locales (from backup publish_details), scan-gated. + setConfig(config, bulkPublish); + const bulkPublishLimit = fetchBulkPublishLimit(stack?.org_uid); + await getAssetsFromBackup(stack, backupDir, bulkPublish, apiVersion, bulkPublishLimit); } else if (folderUid) { setConfig(config, bulkPublish); const bulkPublishLimit = fetchBulkPublishLimit(stack?.org_uid); for (const locale of locales) { await getAssets(stack, folderUid, bulkPublish, environments, locale, apiVersion, bulkPublishLimit); } + + // Resolve in-queue assets with incremental retry; pass pendingAssetsForRetry explicitly + if (pendingAssetsForRetry.length > 0) { + await processPendingAssets(pendingAssetsForRetry, stack, bulkPublish, environments, apiVersion, bulkPublishLimit); + pendingAssetsForRetry = []; + } } } module.exports = { getAssets, + getAssetsFromBackup, setConfig, start, + processPendingAssets, }; diff --git a/packages/contentstack-bulk-publish/src/util/asset-scan.js b/packages/contentstack-bulk-publish/src/util/asset-scan.js new file mode 100644 index 000000000..27c3ffe77 --- /dev/null +++ b/packages/contentstack-bulk-publish/src/util/asset-scan.js @@ -0,0 +1,121 @@ +/* eslint-disable no-console */ +const chalk = require('chalk'); + +const ASSET_SCAN_STATUS = { + READY: 'clean', + QUARANTINE: 'quarantined', + IN_QUEUE: 'pending', +}; + +const SCAN_RETRY = { + MAX_RETRIES: 0, + INITIAL_WAIT_MS: 5000, + BACKOFF_FACTOR: 2, +}; + +function getIncrementalWaitMs(attempt) { + return SCAN_RETRY.INITIAL_WAIT_MS * Math.pow(SCAN_RETRY.BACKOFF_FACTOR, attempt); +} + +/** + * Batch-fetch asset scan statuses for a list of UIDs. + * Returns a Map. UIDs with no scan data map to undefined. + * Throws on API error — callers must not silently treat failures as "ready". + */ +async function fetchScanStatusBatch(stack, uids) { + const statusMap = new Map(); + if (!uids || uids.length === 0) return statusMap; + + const BATCH_SIZE = 100; + for (let i = 0; i < uids.length; i += BATCH_SIZE) { + const batch = uids.slice(i, i + BATCH_SIZE); + const response = await stack + .asset() + .query({ uid: { $in: batch }, include_asset_scan_status: true, limit: BATCH_SIZE }) + .find(); + for (const asset of response.items || []) { + statusMap.set(asset.uid, asset._asset_scan_status); + } + } + + return statusMap; +} + +/** + * Retry pending (in-queue) assets with incremental backoff until they become + * clean or max retries is reached. + * + * Wait series: 5s, 10s, 20s, 40s, 80s (5 attempts total, max 155s). + * + * @param {object} stack - Management SDK stack instance + * @param {string[]} pendingUids - UIDs currently in scan queue + * @returns {string[]} UIDs that became clean and are safe to publish + */ +async function resolveInQueueAssets(stack, pendingUids) { + if (!pendingUids || pendingUids.length === 0) return []; + + const totalWaitSec = + Array.from({ length: SCAN_RETRY.MAX_RETRIES }, (_, i) => getIncrementalWaitMs(i)).reduce( + (a, b) => a + b, + 0, + ) / 1000; + console.log( + chalk.yellow( + `Resolving ${pendingUids.length} in-queue asset(s). Max wait: ${totalWaitSec}s over ${SCAN_RETRY.MAX_RETRIES} retries.`, + ), + ); + + let remaining = [...pendingUids]; + const resolvedUids = []; + + for (let attempt = 0; attempt < SCAN_RETRY.MAX_RETRIES && remaining.length > 0; attempt++) { + const waitMs = getIncrementalWaitMs(attempt); + console.log( + chalk.yellow( + `Asset scan: ${remaining.length} asset(s) in queue. Waiting ${waitMs / 1000}s before retry ${attempt + 1}/${ + SCAN_RETRY.MAX_RETRIES + }...`, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, waitMs)); + + const statusMap = await fetchScanStatusBatch(stack, remaining); + const stillPending = []; + + for (const uid of remaining) { + const status = statusMap.get(uid); + if (status === ASSET_SCAN_STATUS.QUARANTINE) { + console.log(chalk.red(`Skipped (quarantined after retry): Asset UID '${uid}'`)); + } else if (status === ASSET_SCAN_STATUS.IN_QUEUE) { + stillPending.push(uid); + } else { + // clean or undefined (scanning disabled) — publishable + resolvedUids.push(uid); + } + } + + remaining = stillPending; + } + + if (remaining.length > 0) { + console.warn( + chalk.red( + `Asset scan: ${remaining.length} asset(s) remained in queue after ${SCAN_RETRY.MAX_RETRIES} retries and will be skipped.`, + ), + ); + for (const uid of remaining) { + console.warn(chalk.red(`Skipped (max retries exceeded): Asset UID '${uid}'`)); + } + } + + return resolvedUids; +} + +module.exports = { + ASSET_SCAN_STATUS, + SCAN_RETRY, + getIncrementalWaitMs, + fetchScanStatusBatch, + resolveInQueueAssets, +}; diff --git a/packages/contentstack-bulk-publish/test/unit/util/asset-scan.test.js b/packages/contentstack-bulk-publish/test/unit/util/asset-scan.test.js new file mode 100644 index 000000000..83b430bde --- /dev/null +++ b/packages/contentstack-bulk-publish/test/unit/util/asset-scan.test.js @@ -0,0 +1,162 @@ +'use strict'; + +const { describe, it, beforeEach, afterEach } = require('mocha'); +const { expect } = require('chai'); + +const { + ASSET_SCAN_STATUS, + SCAN_RETRY, + getIncrementalWaitMs, + fetchScanStatusBatch, + resolveInQueueAssets, +} = require('../../../src/util/asset-scan'); + +// Minimal mock stack factory +function makeStack(items) { + return { + asset() { + return { + query() { + return { + find: async () => ({ items: items || [] }), + }; + }, + }; + }, + }; +} + +// Stack that throws on query +function makeErrorStack(errorMsg) { + return { + asset() { + return { + query() { + return { + find: async () => { + throw new Error(errorMsg); + }, + }; + }, + }; + }, + }; +} + +describe('asset-scan utilities', () => { + // ─── getIncrementalWaitMs ──────────────────────────────────────────────── + + describe('getIncrementalWaitMs', () => { + it('returns INITIAL_WAIT_MS for attempt 0', () => { + expect(getIncrementalWaitMs(0)).to.equal(SCAN_RETRY.INITIAL_WAIT_MS); + }); + + it('doubles on each subsequent attempt', () => { + const seq = [0, 1, 2, 3, 4].map(getIncrementalWaitMs); + for (let i = 1; i < seq.length; i++) { + expect(seq[i]).to.equal(seq[i - 1] * SCAN_RETRY.BACKOFF_FACTOR); + } + }); + + it('produces the correct 5-attempt sequence', () => { + const expected = [5000, 10000, 20000, 40000, 80000]; + expected.forEach((ms, attempt) => { + expect(getIncrementalWaitMs(attempt)).to.equal(ms); + }); + }); + }); + + // ─── fetchScanStatusBatch ─────────────────────────────────────────────── + + describe('fetchScanStatusBatch', () => { + it('returns empty Map when called with empty uid array', async () => { + const map = await fetchScanStatusBatch(makeStack([]), []); + expect(map.size).to.equal(0); + }); + + it('maps UIDs to their scan statuses', async () => { + const items = [ + { uid: 'a1', _asset_scan_status: 'clean' }, + { uid: 'a2', _asset_scan_status: 'quarantined' }, + { uid: 'a3', _asset_scan_status: 'pending' }, + ]; + const map = await fetchScanStatusBatch(makeStack(items), ['a1', 'a2', 'a3']); + expect(map.get('a1')).to.equal(ASSET_SCAN_STATUS.READY); + expect(map.get('a2')).to.equal(ASSET_SCAN_STATUS.QUARANTINE); + expect(map.get('a3')).to.equal(ASSET_SCAN_STATUS.IN_QUEUE); + }); + + it('maps UIDs with no scan field to undefined', async () => { + const items = [{ uid: 'a1' }]; + const map = await fetchScanStatusBatch(makeStack(items), ['a1']); + expect(map.get('a1')).to.equal(undefined); + }); + + it('throws on API error (fail fast — do not silently treat as ready)', async () => { + try { + await fetchScanStatusBatch(makeErrorStack('Network error'), ['a1']); + expect.fail('Expected fetchScanStatusBatch to throw'); + } catch (error) { + expect(error.message).to.equal('Network error'); + } + }); + }); + + // ─── resolveInQueueAssets ─────────────────────────────────────────────── + + describe('resolveInQueueAssets', () => { + let originalSetTimeout; + + beforeEach(() => { + // Replace setTimeout with an immediate resolver to avoid real waits + originalSetTimeout = global.setTimeout; + global.setTimeout = (fn) => fn(); + }); + + afterEach(() => { + global.setTimeout = originalSetTimeout; + }); + + it('returns empty array for empty input without calling stack', async () => { + const result = await resolveInQueueAssets(makeStack([]), []); + expect(result).to.deep.equal([]); + }); + + it('resolves UIDs that become clean on the first retry', async () => { + const items = [{ uid: 'a1', _asset_scan_status: 'clean' }]; + const result = await resolveInQueueAssets(makeStack(items), ['a1']); + expect(result).to.include('a1'); + }); + + it('excludes UIDs that become quarantined during retry', async () => { + const items = [{ uid: 'a1', _asset_scan_status: 'quarantined' }]; + const result = await resolveInQueueAssets(makeStack(items), ['a1']); + expect(result).to.not.include('a1'); + }); + + it('resolves UIDs with no scan status (scanning disabled)', async () => { + const items = [{ uid: 'a1' }]; // no _asset_scan_status field + const result = await resolveInQueueAssets(makeStack(items), ['a1']); + expect(result).to.include('a1'); + }); + + it('drops UIDs still pending after MAX_RETRIES', async () => { + // Always returns pending status + const items = [{ uid: 'a1', _asset_scan_status: 'pending' }]; + const result = await resolveInQueueAssets(makeStack(items), ['a1']); + expect(result).to.deep.equal([]); + }); + + it('handles mixed outcomes: clean, quarantined, and pending exhausted', async () => { + const items = [ + { uid: 'clean1', _asset_scan_status: 'clean' }, + { uid: 'quar1', _asset_scan_status: 'quarantined' }, + { uid: 'pend1', _asset_scan_status: 'pending' }, + ]; + const result = await resolveInQueueAssets(makeStack(items), ['clean1', 'quar1', 'pend1']); + expect(result).to.include('clean1'); + expect(result).to.not.include('quar1'); + expect(result).to.not.include('pend1'); + }); + }); +}); diff --git a/packages/contentstack-export/package.json b/packages/contentstack-export/package.json index ece2abd96..def2158fb 100644 --- a/packages/contentstack-export/package.json +++ b/packages/contentstack-export/package.json @@ -93,7 +93,8 @@ "shortCommandName": { "cm:stacks:export": "EXPRT", "cm:export": "O-EXPRT" - } + }, + "planProtectedFeatures": ["assetsScan"] }, "repository": "https://github.com/contentstack/cli" } diff --git a/packages/contentstack-export/src/commands/cm/stacks/export.ts b/packages/contentstack-export/src/commands/cm/stacks/export.ts index 15f88c573..d7dd749e5 100644 --- a/packages/contentstack-export/src/commands/cm/stacks/export.ts +++ b/packages/contentstack-export/src/commands/cm/stacks/export.ts @@ -120,7 +120,7 @@ export default class ExportCommand extends Command { let exportDir: string = pathValidator('logs'); try { const { flags } = await this.parse(ExportCommand); - const exportConfig = await setupExportConfig(flags); + const exportConfig = await setupExportConfig(flags, this.context); // Store apiKey in configHandler for session.json (return value not needed) createLogContext( diff --git a/packages/contentstack-export/src/types/export-config.ts b/packages/contentstack-export/src/types/export-config.ts index 8b0e1b37b..a6fd5fe4c 100644 --- a/packages/contentstack-export/src/types/export-config.ts +++ b/packages/contentstack-export/src/types/export-config.ts @@ -1,3 +1,4 @@ +import { FeatureStatus } from '@contentstack/cli-utilities'; import { Context, Modules, Region } from '.'; import DefaultConfig from './default-config'; @@ -36,6 +37,7 @@ export default interface ExportConfig extends DefaultConfig { skipStackSettings?: boolean; skipDependencies?: boolean; authenticationMethod?: string; + planStatus?: Record; } type branch = { diff --git a/packages/contentstack-export/src/utils/export-config-handler.ts b/packages/contentstack-export/src/utils/export-config-handler.ts index c67b6c12b..bf984f3da 100644 --- a/packages/contentstack-export/src/utils/export-config-handler.ts +++ b/packages/contentstack-export/src/utils/export-config-handler.ts @@ -1,6 +1,14 @@ import merge from 'merge'; import * as path from 'path'; -import { configHandler, isAuthenticated,cliux, sanitizePath, log } from '@contentstack/cli-utilities'; +import { + configHandler, + isAuthenticated, + cliux, + sanitizePath, + log, + FeatureCtx, + isFeatureEnabled, +} from '@contentstack/cli-utilities'; import defaultConfig from '../config'; import { readFile } from './file-helper'; import { askExportDir, askAPIKey } from './interactive'; @@ -8,7 +16,7 @@ import login from './basic-login'; import { filter, includes } from 'lodash'; import { ExportConfig } from '../types'; -const setupConfig = async (exportCmdFlags: any): Promise => { +const setupConfig = async (exportCmdFlags: any, context?: any): Promise => { let config = merge({}, defaultConfig); // Track authentication method @@ -97,7 +105,7 @@ const setupConfig = async (exportCmdFlags: any): Promise => { if (exportCmdFlags['branch-alias']) { config.branchAlias = exportCmdFlags['branch-alias']; - } + } if (exportCmdFlags['branch']) { config.branchName = exportCmdFlags['branch']; } @@ -133,10 +141,36 @@ const setupConfig = async (exportCmdFlags: any): Promise => { } } - // Add authentication details to config for context tracking + // Add authentication details to config for context tracking config.authenticationMethod = authenticationMethod; log.debug('Export configuration setup completed.', { ...config }); + // Deferred plan check — credentials now available after setupExportConfig + 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(`[export] Deferred plan status fetched for "${featureUid}".`); + } catch (error) { + log.warn(`[export] Could not fetch deferred plan status for "${featureUid}": ${(error as Error).message}`); + } + } + } + + if (context?.planStatus) { + config.planStatus = context.planStatus; + } + return config; }; diff --git a/packages/contentstack-external-migrate/tsconfig.tsbuildinfo b/packages/contentstack-external-migrate/tsconfig.tsbuildinfo new file mode 100644 index 000000000..758731050 --- /dev/null +++ b/packages/contentstack-external-migrate/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/index.ts","./src/adapters/registry.ts","./src/adapters/types.ts","./src/adapters/contentful/convert.ts","./src/adapters/contentful/export.ts","./src/adapters/contentful/index.ts","./src/adapters/contentful/validator.ts","./src/commands/migrate/audit.ts","./src/commands/migrate/convert.ts","./src/commands/migrate/create.ts","./src/commands/migrate/export.ts","./src/commands/migrate/import.ts","./src/commands/migrate/status.ts","./src/lib/bundle.ts","./src/lib/clear-import-state.ts","./src/lib/contentful-cli-spawn.ts","./src/lib/conversion-summary.ts","./src/lib/create-stack.ts","./src/lib/csdx-spawn.ts","./src/lib/helpers.ts","./src/lib/local-date.ts","./src/lib/log.ts","./src/lib/manifest.ts","./src/lib/parse-json-loose.ts","./src/services/contentful/config.ts","./src/services/contentful/constants.ts","./src/services/contentful/content-type-creator.ts","./src/services/contentful/contentful.service.ts","./src/services/contentful/extension.service.ts","./src/services/contentful/market-app.utils.ts","./src/services/contentful/marketplace.service.ts","./src/services/contentful/releases.ts","./src/services/contentful/scheduled.ts","./src/services/contentful/tasks.ts","./src/services/contentful/types.ts","./src/services/contentful/users.ts","./src/services/contentful/workflows.ts","./src/services/contentful/contentful/jsonrte.ts","./src/services/contentful/contentful/markdown.ts","./src/services/contentful/contentful/roles.ts","./src/services/contentful/contentful/taxonomy.service.ts","./src/services/contentful/mapper/write.ts","./src/services/contentful/migration-contentful/index.js","./src/services/contentful/migration-contentful/libs/contenttypemapper.js","./src/services/contentful/migration-contentful/libs/createinitialmapper.js","./src/services/contentful/migration-contentful/libs/extractcontenttypes.js","./src/services/contentful/migration-contentful/libs/extractlocale.js","./src/services/contentful/migration-contentful/libs/extracttaxonomy.js","./src/services/contentful/migration-contentful/utils/helper.js","./src/services/contentful/prompts/master-locale.ts","./src/services/contentful/utils/custom-logger.utils.ts","./src/services/contentful/utils/index.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/contentstack-import/package.json b/packages/contentstack-import/package.json index d26a96032..9b72827aa 100644 --- a/packages/contentstack-import/package.json +++ b/packages/contentstack-import/package.json @@ -85,7 +85,8 @@ "shortCommandName": { "cm:stacks:import": "IMPRT", "cm:import": "O-IMPRT" - } + }, + "planProtectedFeatures": ["assetsScan"] }, "repository": "https://github.com/contentstack/cli" } diff --git a/packages/contentstack-import/src/commands/cm/stacks/import.ts b/packages/contentstack-import/src/commands/cm/stacks/import.ts index 74217cf45..0d795b66b 100644 --- a/packages/contentstack-import/src/commands/cm/stacks/import.ts +++ b/packages/contentstack-import/src/commands/cm/stacks/import.ts @@ -154,7 +154,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:export', @@ -187,6 +187,23 @@ export default class ImportCommand extends Command { log.success(`The log has been stored at: ${getLogPath()}`, importConfig.context); log.info(`The backup content has been stored at: ${backupDir}`, importConfig.context); + + // Closing reminder: when assets were imported but not published inline + // (asset scanning enabled, or --skip-assets-publish), point the user to + // cm:assets:publish with the backup dir and stack pre-filled so the note + // isn't lost in the per-module logs above. + const assetsImported = importConfig.moduleName + ? importConfig.moduleName === 'assets' + : importConfig.modules?.types?.includes('assets'); + // Mirror the publish gate in assets.ts (`!skipAssetsPublish`): assets are + // left unpublished exactly when skipAssetsPublish is set — which also + // covers the scanning case, since detecting scanning sets skipAssetsPublish. + if (!result?.noSuccessMsg && assetsImported && importConfig.skipAssetsPublish) { + log.info( + `Note: assets were imported but not published asset scanning is enabled and must complete first. To publish them, run:\n csdx cm:assets:publish --backup-dir ${backupDir} --stack-api-key ${importConfig.apiKey}`, + importConfig.context, + ); + } } catch (error) { handleAndLogError(error); log.info(`The log has been stored at '${getLogPath()}'`); diff --git a/packages/contentstack-import/src/import/modules/assets.ts b/packages/contentstack-import/src/import/modules/assets.ts index e8b792f0b..7e4a13111 100644 --- a/packages/contentstack-import/src/import/modules/assets.ts +++ b/packages/contentstack-import/src/import/modules/assets.ts @@ -53,11 +53,15 @@ export default class ImportAssets extends BaseClass { */ async start(): Promise { try { - // NOTE Step 1: Import folders and create uid mapping file + if (this.importConfig.assetScanningEnabled) { + log.info('Assets Scanning is enabled in this stack', this.importConfig.context); + log.warn('Assets publishing will be skipped', this.importConfig.context); + } + // NOTE Step 1: Import folders and create uid mapping file log.debug('Starting folder import process...', this.importConfig.context); await this.importFolders(); - // NOTE Step 2: Import versioned assets and create it mapping files (uid, url) + // NOTE Step 2: Import versioned assets and create it mapping files (uid, url) if (this.assetConfig.includeVersionedAssets) { const versionsPath = `${this.assetsPath}/versions`; if (existsSync(versionsPath)) { @@ -68,17 +72,31 @@ export default class ImportAssets extends BaseClass { } } - // NOTE Step 3: Import Assets and create it mapping files (uid, url) + // NOTE Step 3: Import Assets and create it mapping files (uid, url) log.debug('Starting assets import...', this.importConfig.context); await this.importAssets(); - // NOTE Step 4: Publish assets + // NOTE Step 4: Publish assets if (!this.importConfig.skipAssetsPublish) { log.debug('Starting assets publishing...', this.importConfig.context); await this.publish(); } log.success('Assets imported successfully!', this.importConfig.context); + + // Only surface the "publish later" guidance when assets were actually + // imported. With 0 assets, assetsUidMap is empty and the backup has no + // assets to publish — printing the guidance would send the user to run + // cm:assets:publish against an empty backup. + if (this.importConfig.assetScanningEnabled && !isEmpty(this.assetsUidMap)) { + 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:assets:publish --data-dir ${this.importConfig.backupDir} --stack-api-key [STACK API KEY]`, + this.importConfig.context, + ); + } } catch (error) { handleAndLogError(error, { ...this.importConfig.context }); } diff --git a/packages/contentstack-import/src/types/import-config.ts b/packages/contentstack-import/src/types/import-config.ts index 2c4c9a000..80805ae2c 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'; @@ -15,6 +16,7 @@ export default interface ImportConfig extends DefaultConfig, ExternalConfig { skipAssetsPublish?: boolean; skipEntriesPublish?: boolean; cliLogsPath: string; + assetScanningEnabled?: boolean; canCreatePrivateApp: boolean; contentDir: string; data: string; @@ -59,6 +61,7 @@ export default interface ImportConfig extends DefaultConfig, ExternalConfig { personalizeProjectName?: string; 'exclude-global-modules': false; context: Context; + 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 9df8a1bbd..e25a93034 100644 --- a/packages/contentstack-import/src/utils/import-config-handler.ts +++ b/packages/contentstack-import/src/utils/import-config-handler.ts @@ -7,6 +7,8 @@ import { cliux, sanitizePath, log, + isFeatureEnabled, + FeatureCtx, } from '@contentstack/cli-utilities'; import defaultConfig from '../config'; import { readFile, fileExistsSync } from './file-helper'; @@ -14,7 +16,7 @@ import { askContentDir, askAPIKey } from './interactive'; import login from './login-handler'; import { ImportConfig } from '../types'; -const setupConfig = async (importCmdFlags: any): Promise => { +const setupConfig = async (importCmdFlags: any, context?: any): Promise => { let config: ImportConfig = merge({}, defaultConfig); // Track authentication method let authenticationMethod = 'unknown'; @@ -139,6 +141,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; };