@@ -2,12 +2,13 @@ import { resolve as pResolve } from 'node:path';
22import { Readable } from 'node:stream' ;
33import { mkdir , writeFile } from 'node:fs/promises' ;
44import chunk from 'lodash/chunk' ;
5- import { configHandler , log , FsUtility } from '@contentstack/cli-utilities' ;
5+ import { log , FsUtility } from '@contentstack/cli-utilities' ;
66
77import type { CSAssetsAPIConfig , LinkedWorkspace } from '../types/cs-assets-api' ;
88import type { ExportContext } from '../types/export-types' ;
99import { CSAssetsExportAdapter } from './base' ;
10- import { writeStreamToFile , getArrayFromResponse } from '../utils/export-helpers' ;
10+ import { writeStreamToFile , getArrayFromResponse , getSecuredAssetAuth , SecuredAssetAuthError } from '../utils/export-helpers' ;
11+ import type { SecuredAssetAuth } from '../utils/export-helpers' ;
1112import { forEachChunkedJsonStore } from '../utils/chunked-json-reader' ;
1213import { withRetry , RetryableHttpError , isRetryableStatus , parseRetryAfterMs } from '../utils/retry' ;
1314import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter' ;
@@ -17,8 +18,11 @@ const ASSET_META_KEYS = ['uid', 'url', 'filename', 'file_name', 'parent_uid'];
1718
1819type AssetRecord = { uid ?: string ; _uid ?: string ; url ?: string ; filename ?: string ; file_name ?: string } ;
1920
20- /** Per-space export counts surfaced to the summary (assets = downloaded binaries; folders = entities). */
21- export type SpaceExportCounts = { assets : number ; folders : number } ;
21+ /**
22+ * Per-space export counts surfaced to the summary (assets = downloaded binaries; folders = entities;
23+ * failedAssets = metadata records missing after permanent page failures + binary download failures).
24+ */
25+ export type SpaceExportCounts = { assets : number ; folders : number ; failedAssets : number } ;
2226
2327export default class ExportAssets extends CSAssetsExportAdapter {
2428 constructor ( apiConfig : CSAssetsAPIConfig , exportContext : ExportContext ) {
@@ -53,11 +57,22 @@ export default class ExportAssets extends CSAssetsExportAdapter {
5357 } ;
5458
5559 log . debug ( `Fetching folders and streaming assets for space ${ workspace . space_uid } ` , this . exportContext . context ) ;
56- const [ folders ] = await Promise . all ( [
60+ const [ folders , streamResult ] = await Promise . all ( [
5761 this . getWorkspaceFolders ( workspace . space_uid , workspace . uid , this . apiPageSize , this . apiFetchConcurrency ) ,
5862 this . streamWorkspaceAssets ( workspace . space_uid , workspace . uid , onPage , this . apiPageSize , this . apiFetchConcurrency ) ,
5963 ] ) ;
6064
65+ // Permanently failed metadata pages (or mid-export drift) — these records never reached
66+ // assets.json, so the download loop can't see them. Count them as failures in the summary;
67+ // they are recoverable via a re-export or a targeted query-export, so don't abort the run.
68+ if ( streamResult . missing > 0 ) {
69+ log . error (
70+ `${ streamResult . missing } asset metadata record(s) could not be fetched for space ${ workspace . space_uid } — ` +
71+ 'they are missing from this export. Re-export (or use query-export) to recover them.' ,
72+ this . exportContext . context ,
73+ ) ;
74+ }
75+
6176 if ( fsWriter ) fsWriter . completeFile ( true ) ;
6277 else await this . writeEmptyChunkedJson ( assetsDir , 'assets.json' ) ;
6378 log . debug ( `Wrote chunked assets metadata (${ totalStreamed } item(s)) under ${ assetsDir } ` , this . exportContext . context ) ;
@@ -78,22 +93,25 @@ export default class ExportAssets extends CSAssetsExportAdapter {
7893 this . tick ( true , `metadata: ${ workspace . space_uid } (${ totalStreamed } )` , null ) ;
7994
8095 log . debug ( `Starting binary downloads for space ${ workspace . space_uid } ` , this . exportContext . context ) ;
81- const assetsDownloaded = await this . downloadWorkspaceAssets ( assetsDir , workspace . space_uid , downloadableCount ) ;
96+ const downloads = await this . downloadWorkspaceAssets ( assetsDir , workspace . space_uid , downloadableCount ) ;
8297
8398 const folderCount = getArrayFromResponse ( folders , 'folders' ) . length ;
84- return { assets : assetsDownloaded , folders : folderCount } ;
99+ return { assets : downloads . ok , folders : folderCount , failedAssets : streamResult . missing + downloads . fail } ;
85100 }
86101
87102 /**
88103 * Download asset binaries by reading the just-written chunked `assets.json` back from disk
89104 * (one chunk at a time), so we never re-materialize the whole asset list in memory.
90105 */
91- private async downloadWorkspaceAssets ( assetsDir : string , spaceUid : string , expectedDownloads : number ) : Promise < number > {
106+ private async downloadWorkspaceAssets (
107+ assetsDir : string ,
108+ spaceUid : string ,
109+ expectedDownloads : number ,
110+ ) : Promise < { ok : number ; fail : number } > {
92111 const filesDir = pResolve ( assetsDir , 'files' ) ;
93112 await mkdir ( filesDir , { recursive : true } ) ;
94113
95114 const securedAssets = this . exportContext . securedAssets ?? false ;
96- const authtoken = securedAssets ? configHandler . get ( 'authtoken' ) : null ;
97115 log . debug (
98116 `Asset downloads: securedAssets=${ securedAssets } , concurrency=${ this . downloadAssetsBatchConcurrency } , expected=${ expectedDownloads } ` ,
99117 this . exportContext . context ,
@@ -102,6 +120,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
102120
103121 let downloadOk = 0 ;
104122 let downloadFail = 0 ;
123+ // Set when a 401 persists after a forced token refresh — from then on, skip the network
124+ // entirely and abort the phase, instead of individually failing every remaining asset.
125+ let authFailure : SecuredAssetAuthError | null = null ;
105126
106127 await forEachChunkedJsonStore < AssetRecord > (
107128 assetsDir ,
@@ -136,25 +157,60 @@ export default class ExportAssets extends CSAssetsExportAdapter {
136157 async ( records ) => {
137158 const valid = records . filter ( ( asset ) => this . isDownloadable ( asset ) ) ;
138159 if ( valid . length === 0 ) return ;
160+ const chunkAuthFailure = authFailure ;
161+ if ( chunkAuthFailure ) {
162+ // Auth already failed hard — count the remaining downloadables without hitting the network.
163+ for ( const rec of valid ) {
164+ downloadFail += 1 ;
165+ this . tick ( false , `asset: ${ rec . filename ?? rec . file_name ?? rec . uid ?? 'asset' } ` , chunkAuthFailure . message ) ;
166+ }
167+ return ;
168+ }
169+ // Resolve per chunk so long download phases pick up proactively refreshed OAuth tokens.
170+ let auth : SecuredAssetAuth = securedAssets ? await getSecuredAssetAuth ( ) : { } ;
139171 const apiBatches = chunk ( valid , this . downloadAssetsBatchConcurrency ) ;
140172 const promisifyHandler : CustomPromiseHandler = async ( { index, batchIndex } ) => {
141173 const asset = apiBatches [ batchIndex ] [ index ] as AssetRecord ;
142174 const uid = ( asset . uid ?? asset . _uid ) as string ;
143175 const url = asset . url as string ;
144176 const filename = asset . filename ?? asset . file_name ?? 'asset' ;
145177 if ( ! url || ! uid ) return ;
178+ const knownAuthFailure = authFailure as SecuredAssetAuthError | null ;
179+ if ( knownAuthFailure ) {
180+ downloadFail += 1 ;
181+ this . tick ( false , `asset: ${ filename } ` , knownAuthFailure . message ) ;
182+ return ;
183+ }
146184 try {
147185 const separator = url . includes ( '?' ) ? '&' : '?' ;
148- const downloadUrl = securedAssets && authtoken ? `${ url } ${ separator } authtoken=${ authtoken } ` : url ;
186+ const doFetch = ( ) =>
187+ fetch (
188+ securedAssets && auth . authtoken ? `${ url } ${ separator } authtoken=${ auth . authtoken } ` : url ,
189+ securedAssets && auth . headers ? { headers : auth . headers } : undefined ,
190+ ) ;
149191 // Binary GET is idempotent — retry transient failures with backoff.
150192 const response = await withRetry (
151193 async ( ) => {
152194 let resp : Response ;
153195 try {
154- resp = await fetch ( downloadUrl ) ;
196+ resp = await doFetch ( ) ;
155197 } catch ( e ) {
156198 throw new RetryableHttpError ( `download network error: ${ ( e as Error ) ?. message ?? String ( e ) } ` ) ;
157199 }
200+ if ( securedAssets && resp . status === 401 ) {
201+ // Token expired or was revoked mid-run — force one refresh (deduped upstream)
202+ // and refetch. A second 401 means auth is unrecoverable: abort the phase.
203+ auth = await getSecuredAssetAuth ( true ) ;
204+ try {
205+ resp = await doFetch ( ) ;
206+ } catch ( e ) {
207+ throw new RetryableHttpError ( `download network error: ${ ( e as Error ) ?. message ?? String ( e ) } ` ) ;
208+ }
209+ if ( resp . status === 401 ) {
210+ authFailure = new SecuredAssetAuthError ( resp . status ) ;
211+ throw authFailure ;
212+ }
213+ }
158214 if ( ! resp . ok ) {
159215 if ( isRetryableStatus ( resp . status ) ) {
160216 throw new RetryableHttpError ( `HTTP ${ resp . status } ` , resp . status , parseRetryAfterMs ( resp . headers . get ( 'retry-after' ) ) ) ;
@@ -191,6 +247,16 @@ export default class ExportAssets extends CSAssetsExportAdapter {
191247 } ,
192248 ) ;
193249
250+ const terminalAuthFailure = authFailure as SecuredAssetAuthError | null ;
251+ if ( terminalAuthFailure ) {
252+ // Fail the space loudly — a "completed with errors" summary would bury the real cause.
253+ log . error (
254+ `Aborted asset downloads for space ${ spaceUid } : ${ terminalAuthFailure . message } ` ,
255+ this . exportContext . context ,
256+ ) ;
257+ throw terminalAuthFailure ;
258+ }
259+
194260 log . info (
195261 downloadFail === 0
196262 ? `Finished downloading ${ downloadOk } asset file(s) for space ${ spaceUid } `
@@ -202,7 +268,7 @@ export default class ExportAssets extends CSAssetsExportAdapter {
202268 this . exportContext . context ,
203269 ) ;
204270
205- return downloadOk ;
271+ return { ok : downloadOk , fail : downloadFail } ;
206272 }
207273
208274}
0 commit comments