@@ -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' ;
@@ -111,7 +112,6 @@ export default class ExportAssets extends CSAssetsExportAdapter {
111112 await mkdir ( filesDir , { recursive : true } ) ;
112113
113114 const securedAssets = this . exportContext . securedAssets ?? false ;
114- const authtoken = securedAssets ? configHandler . get ( 'authtoken' ) : null ;
115115 log . debug (
116116 `Asset downloads: securedAssets=${ securedAssets } , concurrency=${ this . downloadAssetsBatchConcurrency } , expected=${ expectedDownloads } ` ,
117117 this . exportContext . context ,
@@ -120,6 +120,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
120120
121121 let downloadOk = 0 ;
122122 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 ;
123126
124127 await forEachChunkedJsonStore < AssetRecord > (
125128 assetsDir ,
@@ -154,25 +157,60 @@ export default class ExportAssets extends CSAssetsExportAdapter {
154157 async ( records ) => {
155158 const valid = records . filter ( ( asset ) => this . isDownloadable ( asset ) ) ;
156159 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 ( ) : { } ;
157171 const apiBatches = chunk ( valid , this . downloadAssetsBatchConcurrency ) ;
158172 const promisifyHandler : CustomPromiseHandler = async ( { index, batchIndex } ) => {
159173 const asset = apiBatches [ batchIndex ] [ index ] as AssetRecord ;
160174 const uid = ( asset . uid ?? asset . _uid ) as string ;
161175 const url = asset . url as string ;
162176 const filename = asset . filename ?? asset . file_name ?? 'asset' ;
163177 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+ }
164184 try {
165185 const separator = url . includes ( '?' ) ? '&' : '?' ;
166- 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+ ) ;
167191 // Binary GET is idempotent — retry transient failures with backoff.
168192 const response = await withRetry (
169193 async ( ) => {
170194 let resp : Response ;
171195 try {
172- resp = await fetch ( downloadUrl ) ;
196+ resp = await doFetch ( ) ;
173197 } catch ( e ) {
174198 throw new RetryableHttpError ( `download network error: ${ ( e as Error ) ?. message ?? String ( e ) } ` ) ;
175199 }
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+ }
176214 if ( ! resp . ok ) {
177215 if ( isRetryableStatus ( resp . status ) ) {
178216 throw new RetryableHttpError ( `HTTP ${ resp . status } ` , resp . status , parseRetryAfterMs ( resp . headers . get ( 'retry-after' ) ) ) ;
@@ -209,6 +247,16 @@ export default class ExportAssets extends CSAssetsExportAdapter {
209247 } ,
210248 ) ;
211249
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+
212260 log . info (
213261 downloadFail === 0
214262 ? `Finished downloading ${ downloadOk } asset file(s) for space ${ spaceUid } `
0 commit comments