1- import { flags , handleAndLogError , FlagInput } from '@contentstack/cli-utilities' ;
1+ import * as fs from 'fs' ;
2+ import * as path from 'path' ;
23
3- import { ResourceType } from '../../../interfaces' ;
4+ import { flags , handleAndLogError , log } from '@contentstack/cli-utilities' ;
5+
6+ import { AssetPublishData , BulkOperationResult , OperationType , ResourceType } from '../../../interfaces' ;
47import { BaseBulkCommand } from '../../../base-bulk-command' ;
5- import { $t , messages , fetchAssets } from '../../../utils' ;
8+ import { $t , messages , fetchAssets , scanDataDirStats , BATCH_CONSTANTS , categorizeByScanStatus } from '../../../utils' ;
9+ import type { DataDirScanStats } from '../../../utils' ;
10+ import { AssetService } from '../../../services' ;
611
712/**
813 * Bulk operations command for assets
9- * Supports publish, unpublish, and cross publish operations
14+ * Supports publish, unpublish, cross-publish, and data-dir publish operations
1015 */
1116export default class BulkAssets extends BaseBulkCommand {
1217 static description = messages . BULK_ASSETS_DESCRIPTION ;
@@ -32,44 +37,87 @@ export default class BulkAssets extends BaseBulkCommand {
3237
3338 // Revert (unpublish) previously published assets using success log
3439 '<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias' ,
40+
41+ // Publish assets from exported content folder (e.g. after asset scanning clears)
42+ '<%= config.bin %> <%= command.id %> --data-dir ./content --operation publish -k blt123' ,
3543 ] ;
3644
37- static flags : FlagInput = {
45+ static flags = {
3846 ...BaseBulkCommand . baseFlags ,
3947 'folder-uid' : flags . string ( {
4048 description : messages . FOLDER_UID ,
4149 } ) ,
50+ 'data-dir' : flags . string ( {
51+ char : 'd' ,
52+ description : messages . DATA_DIR_FLAG_DESC ,
53+ } ) ,
54+ 'dry-run' : flags . boolean ( {
55+ description : messages . DRY_RUN_FLAG_DESC ,
56+ default : false ,
57+ } ) ,
4258 } ;
4359
4460 protected resourceType : ResourceType = ResourceType . ASSET ;
4561
62+ protected async resolveFlagsInteractively ( flags : any ) : Promise < any > {
63+ if ( flags [ 'data-dir' ] ) {
64+ return flags ;
65+ }
66+ return super . resolveFlagsInteractively ( flags ) ;
67+ }
68+
4669 async run ( ) : Promise < void > {
4770 try {
48- // Handle cross-publish separately if source-env is specified
4971 if ( this . bulkOperationConfig . sourceEnv ) {
5072 await this . handleCrossPublish ( this . parsedFlags ) ;
5173 return ;
5274 }
5375
76+ if ( this . bulkOperationConfig . dataDir ) {
77+ await this . runDataDirFlow ( ) ;
78+ return ;
79+ }
80+
5481 const assets = await this . fetchItems ( ) ;
5582
5683 if ( assets . length === 0 ) {
5784 this . logger . warn ( $t ( messages . NO_ITEMS_FOUND , { resourceType : ResourceType . ASSET } ) ) ;
5885 return ;
5986 }
6087
61- this . logger . info (
62- $t ( messages . FOUND_ASSETS_TO_OPERATE , { count : assets . length , operation : this . parsedFlags . operation || '' } )
63- ) ;
88+ const { clean, pending, quarantined, noStatus } = categorizeByScanStatus ( assets ) ;
89+ const scanningEnabled = clean . length + pending . length + quarantined . length > 0 ;
90+ const publishable = scanningEnabled ? clean : [ ...clean , ...noStatus ] ;
91+
92+ if ( scanningEnabled ) {
93+ // Log individual skipped assets
94+ pending . forEach ( ( a ) => this . logger . warn ( $t ( messages . SCAN_STATUS_SKIPPED_PENDING , { uid : a . uid } ) ) ) ;
95+ quarantined . forEach ( ( a ) => this . logger . warn ( $t ( messages . SCAN_STATUS_SKIPPED_QUARANTINED , { uid : a . uid } ) ) ) ;
6496
65- // Confirm operation
66- const confirmed = await this . confirmOperation ( assets ) ;
97+ this . printScanningDashboard ( {
98+ total : assets . length ,
99+ clean : clean . length ,
100+ pending : pending . length ,
101+ quarantined : quarantined . length ,
102+ } ) ;
103+
104+ if ( publishable . length === 0 ) {
105+ this . logger . warn ( $t ( messages . NO_PUBLISHABLE_ASSETS ) ) ;
106+ return ;
107+ }
108+ } else {
109+ log . info (
110+ $t ( messages . FOUND_ASSETS_TO_OPERATE , { count : assets . length , operation : this . parsedFlags . operation || '' } )
111+ ) ;
112+ }
113+
114+ const confirmed = await this . confirmOperation ( publishable ) ;
67115 if ( ! confirmed ) {
68116 this . logger . warn ( $t ( messages . OPERATION_CANCELLED ) ) ;
69117 return ;
70118 }
71119
72- const result = await this . executeBulkOperation ( assets ) ;
120+ const result = await this . executeBulkOperation ( publishable ) ;
73121 this . printOperationSummary ( result ) ;
74122 } catch ( error ) {
75123 handleAndLogError ( error ) ;
@@ -78,6 +126,218 @@ export default class BulkAssets extends BaseBulkCommand {
78126 }
79127 }
80128
129+ private async runDataDirFlow ( ) : Promise < void > {
130+ const { dataDir, dryRun } = this . bulkOperationConfig ;
131+
132+ // Capture original CLI locales/envs before pass 1 overwrites them on the config.
133+ const cliLocales = [ ...( this . bulkOperationConfig . locales || [ ] ) ] ;
134+ const cliEnvs = [ ...( this . bulkOperationConfig . environments || [ ] ) ] ;
135+
136+ // Pass 1 — count-only scan: no AssetPublishData objects built, one chunk in memory at a time.
137+ let stats : DataDirScanStats ;
138+ try {
139+ stats = await scanDataDirStats ( dataDir ! , cliEnvs , cliLocales , this . logger ) ;
140+ } catch ( err : any ) {
141+ this . logger . error ( $t ( messages . DATA_DIR_READ_ERROR , { path : dataDir ! , error : err . message || String ( err ) } ) ) ;
142+ return ;
143+ }
144+
145+ this . bulkOperationConfig . environments = stats . environments ;
146+ this . bulkOperationConfig . locales = stats . locales ;
147+
148+ // Pass 1.5 — fetch scan status for all target UIDs (post-import UIDs on the destination stack).
149+ const targetUids = Object . values ( stats . assetUidMapper ) ;
150+ const assetService = new AssetService ( this . managementStack , this . deliveryStack , this . logger ) ;
151+ const scanStatusMap = await assetService . fetchScanStatusByUIDs ( targetUids ) ;
152+
153+ let cleanCount = 0 ;
154+ let pendingCount = 0 ;
155+ let quarantinedCount = 0 ;
156+ for ( const uid of targetUids ) {
157+ const status = scanStatusMap . get ( uid ) ;
158+ if ( status === 'pending' ) pendingCount ++ ;
159+ else if ( status === 'quarantined' ) quarantinedCount ++ ;
160+ else cleanCount ++ ; // clean or undefined (scanning disabled) — both are publishable
161+ }
162+
163+ this . printScanningDashboard ( {
164+ total : stats . eligible + stats . skipped + stats . unmapped ,
165+ localSkipped : stats . skipped ,
166+ unmapped : stats . unmapped ,
167+ clean : cleanCount ,
168+ pending : pendingCount ,
169+ quarantined : quarantinedCount ,
170+ } ) ;
171+
172+ if ( cleanCount === 0 ) {
173+ this . logger . warn ( $t ( messages . NO_PUBLISHABLE_ASSETS ) ) ;
174+ return ;
175+ }
176+
177+ // new Array(n) has .length === n but allocates no elements — just for the count.
178+ const confirmed = await this . confirmOperation ( new Array ( cleanCount ) ) ;
179+ if ( ! confirmed ) {
180+ this . logger . warn ( $t ( messages . OPERATION_CANCELLED ) ) ;
181+ return ;
182+ }
183+
184+ if ( dryRun ) {
185+ log . info ( $t ( messages . DATA_DIR_DRY_RUN ) ) ;
186+ return ;
187+ }
188+
189+ // Pass 2 — stream and publish: one chunk at a time, batches of ≤50 items enqueued directly.
190+ // stats.assetUidMapper and stats.assetsIndex are reused from pass 1 — no second disk read.
191+ const result = await this . streamAndPublish (
192+ dataDir ! ,
193+ cliLocales ,
194+ stats . totalItems ,
195+ stats . assetUidMapper ,
196+ stats . assetsIndex ,
197+ scanStatusMap
198+ ) ;
199+ this . printOperationSummary ( result ) ;
200+ }
201+
202+ /**
203+ * Pass 2 of the data-dir flow.
204+ * Reads chunk files one at a time, fills a working batch of ≤50 AssetPublishData items,
205+ * and enqueues each batch directly into the queue manager without ever holding the full
206+ * asset list in memory. Peak memory: one chunk file + one batch of ≤50 items.
207+ *
208+ * assetUidMapper and assetsIndex are passed in from pass 1 to avoid re-reading those files.
209+ * scanStatusMap filters out non-clean assets before enqueueing.
210+ */
211+ private async streamAndPublish (
212+ dataDir : string ,
213+ cliLocales : string [ ] ,
214+ totalItemCount : number ,
215+ assetUidMapper : Record < string , string > ,
216+ assetsIndex : Record < string , string > ,
217+ scanStatusMap : Map < string , string | undefined >
218+ ) : Promise < BulkOperationResult > {
219+ // Snapshot both arrays so in-flight mutations to bulkOperationConfig can't corrupt payloads.
220+ const environments = [ ...this . bulkOperationConfig . environments ! ] ;
221+ const locales = [ ...this . bulkOperationConfig . locales ! ] ;
222+ const operation = this . bulkOperationConfig . operation as OperationType ;
223+ const startTime = Date . now ( ) ;
224+
225+ // Warn early if the mapper is empty — all assets will be skipped and the user needs to know why.
226+ if ( Object . keys ( assetUidMapper ) . length === 0 ) {
227+ this . logger . warn (
228+ 'Asset UID mapper is empty — all assets will be skipped. Ensure the import completed successfully.'
229+ ) ;
230+ }
231+
232+ const useOverrideLocales = cliLocales . length > 0 ;
233+ const BATCH_SIZE = BATCH_CONSTANTS . maxItems ;
234+ // totalItemCount comes from pass 1 using identical counting logic — used as upper bound for totalBatches.
235+ // Scan status filtering may reduce the actual count; the invariant check below will log any mismatch.
236+ const totalBatches = Math . ceil ( totalItemCount / BATCH_SIZE ) ;
237+
238+ let workingBatch : AssetPublishData [ ] = [ ] ;
239+ let batchNumber = 0 ;
240+ let totalSubmitted = 0 ;
241+
242+ this . batchResults . clear ( ) ;
243+
244+ const flushBatch = ( ) : void => {
245+ if ( workingBatch . length === 0 ) return ;
246+ batchNumber ++ ;
247+ this . queueManager . enqueue ( ResourceType . ASSET , operation , {
248+ items : [ ...workingBatch ] ,
249+ environments,
250+ locales,
251+ batchNumber,
252+ totalBatches,
253+ operation,
254+ } ) ;
255+ totalSubmitted += workingBatch . length ;
256+ workingBatch = [ ] ;
257+ } ;
258+
259+ for ( const chunkFilename of Object . values ( assetsIndex ) ) {
260+ const chunkPath = path . join ( dataDir , 'assets' , chunkFilename ) ;
261+ const chunkData : Record < string , any > = JSON . parse ( fs . readFileSync ( chunkPath , 'utf-8' ) ) ;
262+
263+ for ( const asset of Object . values ( chunkData ) ) {
264+ if ( ! asset . publish_details || asset . publish_details . length === 0 ) continue ;
265+ const targetUid = assetUidMapper [ asset . uid as string ] ;
266+ if ( ! targetUid ) continue ;
267+
268+ // Skip assets that did not pass scanning.
269+ const scanStatus = scanStatusMap . get ( targetUid ) ;
270+ if ( scanStatus === 'quarantined' ) {
271+ this . logger . warn ( $t ( messages . SCAN_STATUS_SKIPPED_QUARANTINED , { uid : targetUid } ) ) ;
272+ continue ;
273+ }
274+ if ( scanStatus === 'pending' ) {
275+ this . logger . warn ( $t ( messages . SCAN_STATUS_SKIPPED_PENDING , { uid : targetUid } ) ) ;
276+ continue ;
277+ }
278+
279+ const assetLocales : string [ ] = useOverrideLocales
280+ ? cliLocales
281+ : [ ...new Set < string > ( asset . publish_details . map ( ( pd : any ) => pd . locale as string ) ) ] ;
282+
283+ for ( const locale of assetLocales ) {
284+ workingBatch . push ( { type : 'asset' , uid : targetUid , locale, version : asset . _version } ) ;
285+ if ( workingBatch . length >= BATCH_SIZE ) {
286+ flushBatch ( ) ;
287+ }
288+ }
289+ }
290+ // chunkData falls out of scope here — GC can reclaim it before the next chunk is read.
291+ }
292+
293+ flushBatch ( ) ;
294+
295+ // Invariant: pass 1 and pass 2 use identical counting logic (excluding scan status filtering).
296+ // If batchNumber < totalBatches, scan status filtering reduced the published count — expected.
297+ if ( batchNumber !== totalBatches ) {
298+ this . logger . debug (
299+ `Batch count: predicted ${ totalBatches } , actual ${ batchNumber } . Difference is expected when assets are skipped due to scan status.`
300+ ) ;
301+ }
302+
303+ await this . queueManager . waitForCompletion ( ) ;
304+
305+ const duration = Date . now ( ) - startTime ;
306+ const jobIds = [ ...this . batchResults . values ( ) ] . map ( ( r ) => r . jobId ) . filter ( ( id ) : id is string => ! ! id ) ;
307+
308+ return { success : 0 , failed : 0 , total : totalSubmitted , duration, jobIds } ;
309+ }
310+
311+ private printScanningDashboard ( opts : {
312+ total : number ;
313+ clean : number ;
314+ pending : number ;
315+ quarantined : number ;
316+ localSkipped ?: number ;
317+ unmapped ?: number ;
318+ } ) : void {
319+ const { total, clean, pending, quarantined, localSkipped, unmapped } = opts ;
320+ const SEP = '─' . repeat ( 42 ) ;
321+
322+ log . info ( '' ) ;
323+ log . info ( ` ${ messages . DATA_DIR_ASSET_SCANNING_HEADER } ` ) ;
324+ log . info ( ' ' + SEP ) ;
325+ log . info ( ` ${ messages . DATA_DIR_TOTAL . padEnd ( 38 ) } ${ total } ` ) ;
326+ if ( localSkipped !== undefined ) {
327+ log . warn ( ` ${ messages . DATA_DIR_NO_PUBLISH_DETAILS . padEnd ( 38 ) } ${ localSkipped } ` ) ;
328+ }
329+ if ( unmapped !== undefined ) {
330+ log . warn ( ` ${ messages . DATA_DIR_UNMAPPED . padEnd ( 38 ) } ${ unmapped } ` ) ;
331+ }
332+ log . info ( ' ' + SEP ) ;
333+ log . info ( ` ${ messages . SCAN_STATUS_CLEAN . padEnd ( 38 ) } ${ clean } ` ) ;
334+ if ( pending > 0 ) log . warn ( ` ${ messages . SCAN_STATUS_PENDING . padEnd ( 38 ) } ${ pending } ` ) ;
335+ if ( quarantined > 0 ) log . warn ( ` ${ messages . SCAN_STATUS_QUARANTINED . padEnd ( 38 ) } ${ quarantined } ` ) ;
336+ log . info ( ' ' + SEP ) ;
337+ log . info ( ` ${ messages . DATA_DIR_WILL_PUBLISH . padEnd ( 38 ) } ${ clean } ` ) ;
338+ log . info ( '' ) ;
339+ }
340+
81341 protected async fetchItems ( ) : Promise < any [ ] > {
82342 return await fetchAssets ( this . bulkOperationConfig , this . managementStack , this . deliveryStack , this . logger ) ;
83343 }
0 commit comments