// Node 20+; credentials stay on your server. No dependencies required. import {open,stat} from 'node:fs/promises'; import {basename} from 'node:path'; const filename=process.argv[2]; if(!filename)throw Error('Usage: node '+basename(process.argv[1])+' path/to/course.zip'); const api_key=process.env.SCORMBOX_API_KEY,api_secret=process.env.SCORMBOX_API_SECRET,idempotency_key=process.env.SCORMBOX_IDEMPOTENCY_KEY; if(!api_key||!api_secret||!idempotency_key)throw Error('Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version.'); const base_url=process.env.SCORMBOX_API_BASE_URL||'https://scormbox.com/api/v1',service=new URL(base_url); if(service.protocol!=='https:'&&!(service.protocol==='http:'&&service.hostname==='localhost'))throw Error('Use HTTPS for server credentials, or HTTP localhost for development.'); const authorization='Basic '+Buffer.from(api_key+':'+api_secret).toString('base64'); const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); async function request(path,method='GET',body,headers={}){ const binary=Buffer.isBuffer(body); for(let retry=0;;retry++){ let response,result; try{ response=await fetch(base_url+path,{method,headers:{Authorization:authorization,'Content-Type':binary?'application/octet-stream':'application/json',...(binary?{'Content-Length':String(body.length)}:{}),...headers},body:body===undefined?undefined:binary?body:JSON.stringify(body),redirect:'error',signal:AbortSignal.timeout(120000)}); result=await response.json(); }catch(error){if(retry>=4)throw error;await pause(1000*2**retry);continue;} if(response.ok)return result; if(retry<4&&[409,429,500,502,503,504].includes(response.status)){await pause(1000*2**retry);continue;} throw Error('HTTP '+response.status+': '+result.error); } } const size=(await stat(filename)).size; let course=await request('/courses','POST',{filename:basename(filename),size,shareable:process.env.SCORMBOX_SHAREABLE==='true',...(process.env.SCORMBOX_TITLE?{title:process.env.SCORMBOX_TITLE}:{})},{'Idempotency-Key':idempotency_key}); if(['awaiting_upload','uploading'].includes(course.status)){ const upload=course.upload,file=await open(filename,'r'),saved=new Set(upload.parts.map(part=>part.part_number)); try{ for(let part_number=1;part_number<=upload.part_count;part_number++){ if(saved.has(part_number))continue; const offset=(part_number-1)*upload.part_size,length=Math.min(upload.part_size,size-offset),bytes=Buffer.alloc(length);let used=0; while(useddeadline)throw Error('Preparation is still running. Check course_id '+course.course_id+' before trying again.'); console.log('Course '+course.status+': '+course.preparation.completed_files+' of '+course.preparation.total_files+' files'); await pause(2000); } console.log(JSON.stringify({course_id:course.course_id,format:course.format,management_url:course.management_url,learner_url:course.learner_url},null,2));