1919 */
2020
2121import {
22- createReadStream ,
2322 statSync ,
2423 readFileSync ,
25- writeFileSync ,
2624 mkdirSync ,
27- chmodSync ,
2825 existsSync ,
29- unlinkSync ,
30- readdirSync ,
3126 createWriteStream ,
3227} from "node:fs" ;
33- import { createHash } from "node:crypto" ;
34- import { basename , dirname , join , resolve as resolvePath } from "node:path" ;
35- import { homedir } from "node:os" ;
28+ import { basename , dirname , resolve as resolvePath } from "node:path" ;
3629import { pipeline } from "node:stream/promises" ;
3730
3831import { resolveProjectId } from "./config.mjs" ;
@@ -55,10 +48,7 @@ Options:
5548 --key <dest> Destination key (put only; defaults to file basename)
5649 --content-type <mime> MIME override for blob put (defaults to extension inference)
5750 --private Upload as private (not served by CDN; apikey required to read)
58- --immutable Adds a content-hash suffix to the URL so overwrites produce distinct URLs.
59- Requires computing SHA-256 over the file (CLI does this automatically).
60- --concurrency N Concurrent part PUTs (default 4)
61- --no-resume Start fresh; ignore any cached state
51+ --immutable Append a content-hash suffix to the URL so overwrites produce distinct URLs.
6252 --json NDJSON progress events (for agent consumption)
6353 --prefix <p> Prefix filter (ls only)
6454 --limit <n> Max results (ls only; default 100, max 1000)
@@ -72,6 +62,11 @@ Examples:
7262 run402 assets ls --project prj_abc123 --prefix images/
7363 run402 assets rm images/logo.png --project prj_abc123
7464 run402 assets sign images/logo.png --project prj_abc123 --ttl 600
65+
66+ Note: as of v2.1.0, the CLI delegates to sdk.assets.put which routes through
67+ the unified-apply hero. The pre-v2.1.0 --concurrency and --no-resume flags
68+ are still accepted for backward compatibility but are ignored; resume
69+ semantics now live at the apply-plan level (24h plan TTL).
7570` ;
7671
7772const SUB_HELP = {
@@ -89,15 +84,13 @@ Options:
8984 --content-type <mime> MIME override; defaults to inferring from the destination key extension
9085 --private Upload as private (not served by CDN; apikey required to read)
9186 --immutable Append content-hash suffix so overwrites produce distinct URLs
92- --concurrency N Concurrent part PUTs for multipart uploads (default 4)
93- --no-resume Ignore any cached resumable-upload state and start fresh
9487 --json Emit NDJSON progress events on stdout (for agent consumption)
9588
9689Examples:
9790 run402 assets put ./artifact.tgz --project prj_abc123
9891 run402 assets put ./dist/**/*.png --project prj_abc123 --key assets/
9992 run402 assets put ./asset --project prj_abc123 --key assets/logo --content-type image/svg+xml
100- run402 assets put huge.bin --project prj_abc123 --immutable --concurrency 8
93+ run402 assets put huge.bin --project prj_abc123 --immutable
10194` ,
10295 get : `run402 assets get — Download a blob by key
10396
@@ -185,10 +178,6 @@ Examples:
185178` ,
186179} ;
187180
188- function uploadStateDir ( ) {
189- return join ( homedir ( ) , ".run402" , "uploads" ) ;
190- }
191-
192181function die ( msg , exit_code = 1 ) {
193182 fail ( { code : "BAD_USAGE" , message : msg , exit_code } ) ;
194183}
@@ -255,173 +244,34 @@ function parseContentTypeFlag(name, value) {
255244 return raw ;
256245}
257246
258- async function sha256File ( filePath ) {
259- const h = createHash ( "sha256" ) ;
260- const stream = createReadStream ( filePath ) ;
261- for await ( const chunk of stream ) h . update ( chunk ) ;
262- return h . digest ( "hex" ) ;
263- }
264-
265- function sha256BufferHexAndBase64 ( body ) {
266- const digest = createHash ( "sha256" ) . update ( body ) . digest ( ) ;
267- return {
268- hex : digest . toString ( "hex" ) ,
269- base64 : digest . toString ( "base64" ) ,
270- } ;
271- }
272-
273- function checksumHeadersForPresignedUrl ( url , checksumBase64 ) {
274- let urlHasChecksum = false ;
275- try {
276- urlHasChecksum = new URL ( url ) . searchParams . has ( "x-amz-checksum-sha256" ) ;
277- } catch {
278- urlHasChecksum = false ;
279- }
280- return urlHasChecksum ? { } : { "x-amz-checksum-sha256" : checksumBase64 } ;
281- }
282-
283- function loadState ( uploadId ) {
284- const path = join ( uploadStateDir ( ) , `${ uploadId } .json` ) ;
285- if ( ! existsSync ( path ) ) return null ;
286- try { return JSON . parse ( readFileSync ( path , "utf8" ) ) ; }
287- catch { return null ; }
288- }
289-
290- function saveState ( state ) {
291- const dir = uploadStateDir ( ) ;
292- mkdirSync ( dir , { recursive : true , mode : 0o700 } ) ;
293- chmodSync ( dir , 0o700 ) ;
294- const diskState = { ...state } ;
295- delete diskState . parts ;
296- const path = join ( dir , `${ state . upload_id } .json` ) ;
297- writeFileSync ( path , JSON . stringify ( diskState , null , 2 ) , { mode : 0o600 } ) ;
298- chmodSync ( path , 0o600 ) ;
299- }
300-
301- function removeState ( uploadId ) {
302- const path = join ( uploadStateDir ( ) , `${ uploadId } .json` ) ;
303- if ( existsSync ( path ) ) unlinkSync ( path ) ;
304- }
305-
306- function fileFingerprint ( stat ) {
307- return {
308- file_size : stat . size ,
309- file_mtime_ms : stat . mtimeMs ,
310- } ;
311- }
312-
313- function stateMatchesFile ( state , fingerprint ) {
314- return state . file_size === fingerprint . file_size &&
315- typeof state . file_mtime_ms === "number" &&
316- state . file_mtime_ms === fingerprint . file_mtime_ms ;
317- }
318-
319- function findResumableStateForFile ( projectId , localPath , key , fingerprint ) {
320- const dir = uploadStateDir ( ) ;
321- if ( ! existsSync ( dir ) ) return null ;
322- for ( const f of readdirSync ( dir ) ) {
323- if ( ! f . endsWith ( ".json" ) ) continue ;
324- try {
325- const s = JSON . parse ( readFileSync ( join ( dir , f ) , "utf8" ) ) ;
326- if ( s . project_id === projectId && s . local_path === localPath && s . key === key ) {
327- if ( stateMatchesFile ( s , fingerprint ) ) return s ;
328- removeState ( s . upload_id ) ;
329- }
330- } catch { /* ignore */ }
331- }
332- return null ;
333- }
334-
335247// ---------------------------------------------------------------------------
336248// put
337249// ---------------------------------------------------------------------------
338250
339251async function putOne ( projectId , filePath , opts ) {
340252 const stat = statSync ( filePath ) ;
341- const size = stat . size ;
342- const fingerprint = fileFingerprint ( stat ) ;
343- const destKey = computeDestKey ( filePath , opts . key ) ;
344- const absLocal = resolvePath ( filePath ) ;
345-
346- const sha256 = await sha256File ( filePath ) ;
347-
348- // Attempt to resume
349- let state = opts . resume
350- ? findResumableStateForFile ( projectId , absLocal , destKey , fingerprint )
351- : null ;
352- let initRes ;
353- if ( state ) {
354- // Re-poll the session; if it's still active, resume. Otherwise start fresh.
355- const poll = await getSdk ( ) . assets . getUploadSession ( projectId , state . upload_id ) ;
356- if ( poll . status === "active" ) {
357- log ( opts , { event : "resume" , upload_id : state . upload_id , key : destKey } ) ;
358- initRes = {
359- upload_id : state . upload_id ,
360- mode : poll . mode ?? state . mode ,
361- parts : poll . parts ?? state . parts ?? [ ] ,
362- part_count : poll . part_count ?? state . part_count ,
363- part_size_bytes : poll . part_size_bytes ?? state . part_size_bytes ,
364- } ;
365- } else {
366- removeState ( state . upload_id ) ;
367- state = null ;
368- }
253+ if ( ! stat . isFile ( ) ) {
254+ die ( `Not a regular file: ${ filePath } ` ) ;
369255 }
256+ const destKey = computeDestKey ( filePath , opts . key ) ;
370257
371- if ( ! state ) {
372- initRes = await getSdk ( ) . assets . initUploadSession ( projectId , {
373- key : destKey ,
374- size_bytes : size ,
375- content_type : opts . contentType ?? guessContentType ( destKey ) ,
376- visibility : opts . private ? "private" : "public" ,
377- immutable : opts . immutable ,
378- sha256,
379- } ) ;
380- state = {
381- upload_id : initRes . upload_id ,
382- project_id : projectId ,
383- local_path : absLocal ,
384- key : destKey ,
385- mode : initRes . mode ,
386- part_size_bytes : initRes . part_size_bytes ,
387- part_count : initRes . part_count ,
388- parts : initRes . parts ,
389- parts_done : { } ,
390- sha256,
391- ...fingerprint ,
392- started_at : new Date ( ) . toISOString ( ) ,
393- } ;
394- if ( opts . resume ) saveState ( state ) ;
395- }
396-
397- // Upload parts with concurrency limit. For single-PUT mode part_count=1 and
398- // this loop runs once.
399- const etags = Array ( initRes . part_count ) ;
400- for ( const pn of Object . keys ( state . parts_done || { } ) ) {
401- const pd = state . parts_done [ pn ] ;
402- // Legacy resume state stored just the etag string; new code stores
403- // { etag, sha256 }. Normalize on load.
404- etags [ parseInt ( pn , 10 ) - 1 ] = typeof pd === "string" ? { etag : pd , sha256 : undefined } : pd ;
405- }
406-
407- const todo = initRes . parts . filter ( ( p ) => ! ( state . parts_done || { } ) [ String ( p . part_number ) ] ) ;
408- await withConcurrency ( todo , opts . concurrency , async ( part ) => {
409- const { etag, sha256 : partSha256 } = await putPart ( filePath , part ) ;
410- etags [ part . part_number - 1 ] = { etag, sha256 : partSha256 } ;
411- state . parts_done [ String ( part . part_number ) ] = { etag, sha256 : partSha256 } ;
412- if ( opts . resume ) saveState ( state ) ;
413- log ( opts , { event : "part" , upload_id : state . upload_id , part_number : part . part_number , etag, sha256 : partSha256 } ) ;
414- } ) ;
415-
416- // Complete
417- const body = initRes . mode === "multipart"
418- ? { parts : etags . map ( ( e , i ) => ( { part_number : i + 1 , etag : e . etag , sha256 : e . sha256 } ) ) }
419- : { } ;
420- const result = await getSdk ( ) . assets . completeUploadSession ( projectId , state . upload_id , body , {
258+ // v2.1.0: the legacy /storage/v1/uploads* session API is gone. The CLI
259+ // now delegates to `sdk.assets.put`, which routes through the
260+ // unified-apply hero (apply/v1/plans -> content/v1/plans -> S3 PUT ->
261+ // commit).
262+ //
263+ // Trade-off vs v2.0.x: resumable uploads via persisted state under
264+ // ~/.run402/uploads/ are no longer supported. Resume semantics now live
265+ // at the apply-plan level (24h plan TTL); a future CLI redesign can
266+ // expose that. The --concurrency and --no-resume flags are accepted but
267+ // ignored — the SDK upload paths handle parallelism internally.
268+ log ( opts , { event : "start" , key : destKey , size_bytes : stat . size } ) ;
269+ const bytes = new Uint8Array ( readFileSync ( filePath ) ) ;
270+ const result = await getSdk ( ) . assets . put ( projectId , destKey , { bytes } , {
421271 contentType : opts . contentType ?? guessContentType ( destKey ) ,
272+ visibility : opts . private ? "private" : "public" ,
273+ immutable : opts . immutable ,
422274 } ) ;
423-
424- removeState ( state . upload_id ) ;
425275 log ( opts , { event : "done" , ...result } ) ;
426276 return result ;
427277}
@@ -432,40 +282,6 @@ function computeDestKey(filePath, keyOpt) {
432282 return keyOpt ;
433283}
434284
435- async function putPart ( filePath , part ) {
436- const start = part . byte_start ?? 0 ;
437- const end = part . byte_end ?? ( statSync ( filePath ) . size - 1 ) ;
438- const stream = createReadStream ( filePath , { start, end } ) ;
439- const chunks = [ ] ;
440- for await ( const c of stream ) chunks . push ( c ) ;
441- const body = Buffer . concat ( chunks ) ;
442- const checksum = sha256BufferHexAndBase64 ( body ) ;
443-
444- const res = await fetch ( part . url , {
445- method : "PUT" ,
446- headers : checksumHeadersForPresignedUrl ( part . url , checksum . base64 ) ,
447- body,
448- } ) ;
449- if ( ! res . ok ) {
450- const errBody = await res . text ( ) . catch ( ( ) => "" ) ;
451- throw new Error ( `Part ${ part . part_number } PUT failed: ${ res . status } ${ res . statusText } ${ errBody ? " — " + errBody . slice ( 0 , 200 ) : "" } ` ) ;
452- }
453- const etag = res . headers . get ( "etag" ) ?? "" ;
454- return { etag, sha256 : checksum . hex } ;
455- }
456-
457- async function withConcurrency ( items , limit , worker ) {
458- let index = 0 ;
459- const workerCount = Math . min ( limit , items . length ) ;
460- async function runWorker ( ) {
461- while ( index < items . length ) {
462- const item = items [ index ++ ] ;
463- await worker ( item ) ;
464- }
465- }
466- await Promise . all ( Array . from ( { length : workerCount } , runWorker ) ) ;
467- }
468-
469285async function put ( projectId , argv ) {
470286 const opts = parseArgs ( argv ) ;
471287 opts . project = opts . project || projectId ;
0 commit comments