cesiumcloud 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # CesiumCloud
2
+
3
+ Secure cloud storage CLI and JavaScript SDK. Upload, download, and manage files from your terminal or applications.
4
+
5
+ ## Install
6
+
7
+ Requires Node.js 22 or newer.
8
+
9
+ npm install -g cesiumcloud
10
+
11
+ If Windows PowerShell does not recognize the command after installation, open a new terminal or add the npm global command directory to PATH.
12
+
13
+ ## Commands
14
+
15
+ cesiumcloud authenticate
16
+ cesiumcloud upload "E:\downloads\dogimages.zip"
17
+ cesiumcloud upload "E:\my-backup-folder"
18
+ cesiumcloud download "E:\empty-restore-directory"
19
+ cesiumcloud whoami
20
+ cesiumcloud logout
21
+
22
+ Run `cesiumcloud authenticate` to approve this computer through your CesiumCloud account. Subsequent commands work without manually entering an API key.
23
+
24
+ Interrupted uploads can resume. The `download` command currently restores account backups to the specified directory; selective file download is not yet supported.
25
+
26
+ ## JavaScript SDK
27
+
28
+ const { CesiumCloudClient } = require("cesiumcloud");
29
+ const client = new CesiumCloudClient();
30
+ await client.authenticate({
31
+ onChallenge: ({ verificationUri, userCode }) => {
32
+ console.log("Open", verificationUri, "and enter", userCode);
33
+ }
34
+ });
35
+ await client.uploadFile("E:\\downloads\\dogimages.zip");
36
+ await client.uploadFolder("E:\\my-backup-folder");
37
+
38
+ For device access management, sign in at https://cloud.cesiummc.space/device/manage.html.
39
+
40
+ ## Security
41
+
42
+ Authentication creates a device-specific access credential, stored on the local computer. Treat stored credentials as sensitive. Use the CesiumCloud dashboard to revoke devices when needed. The `logout` command clears the local credential but does not revoke server-side access.
43
+
44
+ ## Support
45
+
46
+ Website: https://cloud.cesiummc.space/
47
+ Documentation: https://api.cesiummc.space/docs
48
+
49
+ ## Existing TenCloud accounts
50
+
51
+ CesiumCloud uses the existing API at https://api.cesiummc.space and reads the legacy ~/.tencloud/credentials.json token so existing installations and API keys continue working. The legacy TenCloud package remains available for compatibility.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /* CesiumCloud CLI v28. Node >=22, dependency-free. */
4
+ const fs=require("node:fs"),fsp=require("node:fs/promises"),path=require("node:path"),os=require("node:os");
5
+ const API=(process.env.CESIUMCLOUD_API_URL||process.env.TENCLOUD_API_URL)||"https://api.cesiummc.space";
6
+ const configDir=process.env.TENCLOUD_CONFIG_DIR||path.join(os.homedir(),".tencloud");
7
+ const credPath=path.join(configDir,"credentials.json");
8
+ const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
9
+ function usage(){
10
+ console.log("CesiumCloud CLI\n\n cesiumcloud authenticate Sign in using the CesiumCloud website\n cesiumcloud upload PATH Upload a file or folder to CesiumCloud\n cesiumcloud download DIR Restore your backups to a directory\n cesiumcloud whoami Show your account\n cesiumcloud logout Remove locally saved credentials\n\nRequires Node.js 22+.");
11
+ }
12
+ async function request(uri,{method="GET",body,token}={}){
13
+ const res=await fetch(API+uri,{method,headers:{accept:"application/json",...(body?{"content-type":"application/json"}:{}),...(token?{authorization:"Bearer "+token}:{})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(40000),cache:"no-store"});
14
+ const data=await res.json().catch(()=>({error:"Invalid server response"}));
15
+ return {res,data};
16
+ }
17
+ async function credential(){
18
+ if(process.env.CESIUMCLOUD_API_KEY||process.env.TENCLOUD_API_KEY)return process.env.CESIUMCLOUD_API_KEY||process.env.TENCLOUD_API_KEY;
19
+ let raw;
20
+ try{raw=JSON.parse(await fsp.readFile(credPath,"utf8"));}catch{return "";}
21
+ return raw.api===API&&/^tc_live_[a-f0-9]{64}$/.test(raw.token||"")?raw.token:"";
22
+ }
23
+ async function save(token){
24
+ await fsp.mkdir(configDir,{recursive:true,mode:0o700});
25
+ const temp=credPath+"."+process.pid+".tmp";
26
+ await fsp.writeFile(temp,JSON.stringify({api:API,token},null,2),{mode:0o600,flag:"wx"});
27
+ await fsp.rename(temp,credPath);
28
+ if(process.platform!=="win32")await fsp.chmod(credPath,0o600);
29
+ }
30
+ async function authenticate(){
31
+ const {res,data}=await request("/v1/device/start",{method:"POST",body:{label:os.hostname().slice(0,40)||"CesiumCloud CLI"}});
32
+ if(!res.ok)throw Error("Unable to begin authorization: "+(data.error||res.status));
33
+ console.log("\nOpen this URL in your browser:\n "+data.verification_uri);
34
+ console.log("\nEnter this device code:\n "+data.user_code);
35
+ console.log("\nSign into your CesiumCloud account and approve the request.");
36
+ console.log("Checking for approval (press Ctrl+C to cancel)...\n");
37
+ const expires=Date.now()+data.expires_in*1000;
38
+ const interval=Math.max(7000,(data.interval||7)*1000);
39
+ while(Date.now()<expires){
40
+ await wait(interval);
41
+ const answer=await request("/v1/device/token",{method:"POST",body:{device_code:data.device_code}});
42
+ if(answer.res.ok&&answer.data.access_token){
43
+ const check=await request("/v1/me",{token:answer.data.access_token});
44
+ if(!check.res.ok)throw Error("CesiumCloud rejected the authorized credential.");
45
+ await save(answer.data.access_token);
46
+ console.log("Authenticated as "+check.data.email+".");
47
+ return;
48
+ }
49
+ if(["authorization_pending","slow_down"].includes(answer.data.error))continue;
50
+ if(["access_denied","expired_token"].includes(answer.data.error))throw Error("Authorization "+answer.data.error.replace("_"," ")+".");
51
+ throw Error("Authorization check failed: "+(answer.data.error||answer.res.status));
52
+ }
53
+ throw Error("Authorization expired; run cesiumcloud authenticate again.");
54
+ }
55
+ async function requireToken(){
56
+ const t=await credential();
57
+ if(!t)throw Error("Not authenticated. Run: cesiumcloud authenticate");
58
+ return t;
59
+ }
60
+ async function cli(){
61
+ if(Number(process.versions.node.split(".")[0])<22)throw Error("Node.js 22+ required.");
62
+ const [cmd,target]=process.argv.slice(2);
63
+ if(["-h","--help","help",undefined].includes(cmd)){usage();return;}
64
+ if(cmd==="authenticate"||cmd==="login"){await authenticate();return;}
65
+ if(cmd==="logout"){
66
+ if(process.env.TENCLOUD_API_KEY)console.log("TENCLOUD_API_KEY is set; unset it separately.");
67
+ await fsp.rm(credPath,{force:true});
68
+ console.log("Saved local credential removed. Revoke server access at the CesiumCloud device management page.");
69
+ return;
70
+ }
71
+ if(cmd==="whoami"){
72
+ const r=await request("/v1/me",{token:await requireToken()});
73
+ if(!r.res.ok)throw Error("Authentication failed. Run: cesiumcloud authenticate");
74
+ console.log(r.data.email+" ("+r.data.access+")");return;
75
+ }
76
+ if(["upload","download"].includes(cmd)){
77
+ if(!target)throw Error("A path is required. Example: cesiumcloud upload \"E:\\downloads\\dogimages.zip\"");
78
+ process.env.TENCLOUD_API_KEY=await requireToken();
79
+ const service=require("../lib/backup.cjs");
80
+ if(cmd==="upload")await service.backup(path.resolve(target));
81
+ else await service.restore(path.resolve(target));
82
+ return;
83
+ }
84
+ throw Error("Unknown command: "+cmd+". Run cesiumcloud --help.");
85
+ }
86
+ cli().catch(e=>{console.error("CesiumCloud error: "+e.message);process.exitCode=1;});
package/lib/backup.cjs ADDED
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ /* TenCloud Backup Client v27
3
+ * Mandatory packed backups, direct-to-Google upload, restart-safe checkpoints.
4
+ * Checkpoints contain pack IDs/fingerprints only (no API key or upload URL).
5
+ * Usage: TENCLOUD_API_KEY=... node TenCloud_BackupClient_v27.cjs backup PATH
6
+ * TENCLOUD_API_KEY=... node TenCloud_BackupClient_v27.cjs restore EMPTY_PATH
7
+ */
8
+ const fs=require("node:fs"),fsp=require("node:fs/promises"),path=require("node:path");
9
+ const os=require("node:os"),crypto=require("node:crypto");
10
+ const {plans,readSlice,parseHeader,parseManifest}=require("./packformat.cjs");
11
+ const API=(process.env.CESIUMCLOUD_API_URL||process.env.TENCLOUD_API_URL)||"https://api.cesiummc.space";
12
+ const TOKEN=process.env.CESIUMCLOUD_API_KEY||process.env.TENCLOUD_API_KEY||"";
13
+ const CHUNK=8*1024*1024,MAX_RETRIES=5;
14
+ const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
15
+ const sha=x=>crypto.createHash("sha256").update(x).digest("hex");
16
+ const uuid=()=>crypto.randomUUID();
17
+ function fmt(n){return n>=1073741824?(n/1073741824).toFixed(2)+" GiB":n>=1048576?(n/1048576).toFixed(2)+" MiB":n+" B";}
18
+ async function api(resource,{method="GET",body}={}){
19
+ const res=await fetch(API+resource,{method,
20
+ headers:{Authorization:"Bearer "+TOKEN,...(body?{"content-type":"application/json"}:{})},
21
+ body:body?JSON.stringify(body):undefined,
22
+ signal:AbortSignal.timeout(40000),cache:"no-store"});
23
+ const raw=await res.text();let data;
24
+ try{data=JSON.parse(raw);}catch{throw Error("Worker returned invalid JSON (HTTP "+res.status+")");}
25
+ if(!res.ok)throw Error("Worker HTTP "+res.status+": "+JSON.stringify(data?.error||data).slice(0,220));
26
+ return data;
27
+ }
28
+ async function queryGoogle(session,total){
29
+ const response=await fetch(session,{method:"PUT",headers:{"Content-Range":"bytes */"+total},signal:AbortSignal.timeout(40000)});
30
+ if(response.status===308){
31
+ const range=response.headers.get("range")||"";
32
+ const match=range.match(/(?:bytes=)?0-(\d+)$/);
33
+ return {offset:match?Number(match[1])+1:0};
34
+ }
35
+ if(response.status===200||response.status===201)
36
+ return {offset:total,done:await response.json()};
37
+ if(response.status===404||response.status===410)throw Error("Google upload session expired; checkpoint retained for recovery");
38
+ throw Error("Google resumable status HTTP "+response.status);
39
+ }
40
+ async function sendDirect(plan,root,session,{resume=false,onAcknowledged}={}){
41
+ let offset=0,done=null,attempt=0;
42
+ if(resume){
43
+ const status=await queryGoogle(session,plan.totalBytes);
44
+ if(status.done)return status.done;
45
+ offset=status.offset;
46
+ console.log("Resuming direct Google Drive pack at byte "+offset+" of "+plan.totalBytes);
47
+ }
48
+ if(offset<0||offset>plan.totalBytes||!Number.isSafeInteger(offset))throw Error("Invalid server upload offset");
49
+ while(offset<plan.totalBytes){
50
+ const n=Math.min(CHUNK,plan.totalBytes-offset);
51
+ // Never buffer more than the current 8 MiB upload chunk.
52
+ const data=await readSlice(root,plan,offset,n);
53
+ let response;
54
+ try{
55
+ response=await fetch(session,{method:"PUT",
56
+ headers:{"content-range":"bytes "+offset+"-"+(offset+n-1)+"/"+plan.totalBytes,
57
+ "content-type":"application/octet-stream","content-length":String(n)},
58
+ body:data,signal:AbortSignal.timeout(180000)});
59
+ }catch(e){
60
+ if(++attempt>MAX_RETRIES)throw Error("Google upload transport error: "+e.message);
61
+ await delay(attempt*1200);
62
+ const probe=await queryGoogle(session,plan.totalBytes);
63
+ if(probe.done)return probe.done;
64
+ offset=probe.offset;continue;
65
+ }
66
+ if(response.status===308){
67
+ const range=response.headers.get("range"),match=range?.match(/(?:bytes=)?0-(\d+)$/);
68
+ // A missing Range on a noninitial acknowledged chunk is unsafe.
69
+ const acknowledged=match?Number(match[1])+1:NaN;
70
+ if(!Number.isSafeInteger(acknowledged)||acknowledged<=offset||acknowledged>plan.totalBytes)
71
+ throw Error("Google resumable chunk acknowledgment was invalid");
72
+ offset=acknowledged;attempt=0;
73
+ if(onAcknowledged)await onAcknowledged(offset,plan.totalBytes);
74
+ continue;
75
+ }
76
+ if(response.ok){
77
+ done=await response.json();
78
+ if(!done?.id)throw Error("Completed Google upload did not return a file ID");
79
+ offset=plan.totalBytes;break;
80
+ }
81
+ if(![403,408,429,500,502,503,504].includes(response.status))
82
+ throw Error("Google rejected upload chunk: HTTP "+response.status);
83
+ if(++attempt>MAX_RETRIES)throw Error("Too many Google upload retries (HTTP "+response.status+")");
84
+ await delay(attempt*1200);
85
+ const probe=await queryGoogle(session,plan.totalBytes);
86
+ if(probe.done)return probe.done;
87
+ offset=probe.offset;
88
+ }
89
+ if(!done?.id)throw Error("Google did not confirm completed upload");
90
+ return done;
91
+ }
92
+ function stateDirectory(){
93
+ return path.resolve(process.env.TENCLOUD_BACKUP_STATE_DIR||path.join(
94
+ process.env.LOCALAPPDATA||path.join(os.homedir(),".local","state"),"TenCloud","checkpoints"));
95
+ }
96
+ function stateFile(root,account,maxPackBytes){
97
+ const identifier=sha(JSON.stringify([path.resolve(root),account.toLowerCase(),maxPackBytes,API]));
98
+ return path.join(stateDirectory(),identifier+".json");
99
+ }
100
+ async function saveState(file,state){
101
+ const temp=file+"."+process.pid+".tmp";
102
+ await fsp.writeFile(temp,JSON.stringify(state,null,2),{mode:0o600,flag:"w"});
103
+ await fsp.rename(temp,file);
104
+ }
105
+ async function loadState(file){
106
+ try{
107
+ const data=JSON.parse(await fsp.readFile(file,"utf8"));
108
+ if(data.version!==2||!Array.isArray(data.completed)||!data.root||!data.account)
109
+ throw Error("Invalid saved checkpoint format");
110
+ return data;
111
+ }catch(e){if(e.code==="ENOENT")return null;throw e;}
112
+ }
113
+ function fingerprint(plan){
114
+ return sha(Buffer.concat([plan.header,Buffer.from(JSON.stringify(plan.entries.map(e=>[e.p,e.o,e.n,e.s,e.m])))]));
115
+ }
116
+ async function lockState(file){
117
+ const lock=file+".lock";
118
+ try{
119
+ const h=await fsp.open(lock,"wx",0o600);
120
+ await h.writeFile(JSON.stringify({pid:process.pid,time:Date.now()}));
121
+ return async()=>{await h.close();await fsp.unlink(lock).catch(()=>{});};
122
+ }catch(e){
123
+ if(e.code!=="EEXIST")throw e;
124
+ // Interrupted/crashed process: reclaim only if OS confirms its PID is gone.
125
+ let previous;
126
+ try{previous=JSON.parse(await fsp.readFile(lock,"utf8"));}catch{}
127
+ if(Number.isInteger(previous?.pid)&&previous.pid>0){
128
+ try{process.kill(previous.pid,0);}catch(err){
129
+ if(err.code==="ESRCH"){await fsp.unlink(lock).catch(()=>{});return lockState(file);}
130
+ }
131
+ }
132
+ throw Error("Another backup may be running, or a stale checkpoint lock exists: "+lock);
133
+ }
134
+ }
135
+ async function backup(folder,options={}){
136
+ if(!/^tc_live_[0-9a-f]{64}$/.test(TOKEN))throw Error("Set TENCLOUD_API_KEY");
137
+ const root=path.resolve(folder),stat=await fsp.stat(root);
138
+ if(!stat.isDirectory()&&!stat.isFile())throw Error("Backup source must be a regular file or directory");
139
+ const maxPackBytes=Number(process.env.TENCLOUD_TEST_PACK_BYTES||0)||64*1024*1024*1024;
140
+ const me=await api("/v1/me");
141
+ const account=String(me.email||"").toLowerCase();
142
+ if(!account)throw Error("Account identity unavailable");
143
+ await fsp.mkdir(stateDirectory(),{recursive:true,mode:0o700});
144
+ const file=stateFile(root,account,maxPackBytes),unlock=await lockState(file);
145
+ let currentPlan=0,packs=0,segments=0,bytes=0;
146
+ try{
147
+ let state=await loadState(file);
148
+ if(state&&(state.root!==root||state.account!==account||state.maxPackBytes!==maxPackBytes))
149
+ throw Error("Checkpoint does not match account, directory or pack size");
150
+ if(!state){
151
+ state={version:2,root,account,maxPackBytes,startedAt:Date.now(),completed:[],current:null};
152
+ await saveState(file,state);
153
+ }else console.log("Found interrupted backup checkpoint; verifying pack positions before resuming");
154
+ for await(const plan of plans(root,{maxPackBytes})){
155
+ const fp=fingerprint(plan),index=currentPlan++;
156
+ const recorded=state.completed[index];
157
+ if(recorded){
158
+ if(recorded.sequence!==index||recorded.fingerprint!==fp)
159
+ throw Error("Source contents changed since checkpoint (pack "+(index+1)+"). Refusing to mix snapshots.");
160
+ packs++;segments+=plan.entries.length;bytes+=plan.payloadBytes;
161
+ console.log("Skipping already committed pack "+(index+1));
162
+ continue;
163
+ }
164
+ if(state.current&&(state.current.sequence!==index||state.current.fingerprint!==fp))
165
+ throw Error("Source contents changed while a pack was unfinished. Checkpoint preserved at "+file);
166
+ if(!state.current){
167
+ state.current={sequence:index,fingerprint:fp,requestId:uuid(),packId:null,driveFileId:null};
168
+ await saveState(file,state);
169
+ }
170
+ const working=state.current;
171
+ let status=null;
172
+ if(working.packId){
173
+ status=await api("/v1/packs/"+working.packId);
174
+ if(status.sizeBytes!==plan.totalBytes||status.segments!==plan.entries.length)
175
+ throw Error("Server pack and local checkpoint differ; refusing to resume");
176
+ if(status.state==="ready"){
177
+ state.completed.push({sequence:index,fingerprint:fp,packId:working.packId});
178
+ state.current=null;await saveState(file,state);
179
+ packs++;segments+=plan.entries.length;bytes+=plan.payloadBytes;
180
+ continue;
181
+ }
182
+ if(status.state==="expired"||status.state==="failed"){
183
+ // A paused backup can outlive Google's resumable upload session.
184
+ // Preserve all committed packs; replace only this unfinished pack.
185
+ await api("/v1/packs/"+working.packId,{method:"DELETE"});
186
+ working.packId=null;working.driveFileId=null;
187
+ working.started=false;working.requestId=uuid();
188
+ await saveState(file,state);
189
+ console.log("Expired pack session replaced; previously committed packs kept");
190
+ }else if(status.state!=="uploading"||!status.uploadUrl)
191
+ throw Error("Upload session cannot resume ("+status.state+"). Checkpoint retained: "+file);
192
+ }
193
+ if(!working.packId){
194
+ const registered=await api("/v1/packs",{method:"POST",body:{
195
+ requestId:working.requestId,sizeBytes:plan.totalBytes,
196
+ sourceBytes:plan.payloadBytes,segments:plan.entries.length
197
+ }});
198
+ working.packId=registered.packId;
199
+ await saveState(file,state);
200
+ status=registered;
201
+ if(status.state==="ready"){
202
+ state.completed.push({sequence:index,fingerprint:fp,packId:working.packId});
203
+ state.current=null;await saveState(file,state);
204
+ packs++;segments+=plan.entries.length;bytes+=plan.payloadBytes;continue;
205
+ }
206
+ }
207
+ let driveFileId=working.driveFileId;
208
+ if(!driveFileId){
209
+ const resuming=!!working.started||!!options.forceGoogleStatusCheck;
210
+ if(!working.started){working.started=true;await saveState(file,state);}
211
+ const complete=await sendDirect(plan,root,status.uploadUrl,{
212
+ resume:resuming,onAcknowledged:options.onAcknowledged
213
+ });
214
+ driveFileId=complete.id;
215
+ working.driveFileId=driveFileId;
216
+ await saveState(file,state);
217
+ }
218
+ const confirmed=await api("/v1/packs/"+working.packId+"/commit",{
219
+ method:"POST",body:{driveFileId}
220
+ });
221
+ if(!confirmed.ok)throw Error("Pack commit not confirmed");
222
+ state.completed.push({sequence:index,fingerprint:fp,packId:working.packId});
223
+ state.current=null;
224
+ await saveState(file,state);
225
+ packs++;segments+=plan.entries.length;bytes+=plan.payloadBytes;
226
+ console.log("Committed pack "+(index+1)+": "+fmt(plan.payloadBytes)+" across "+plan.entries.length+" file segments");
227
+ }
228
+ if(state.completed.length!==currentPlan)
229
+ throw Error("Saved backup manifest mismatch; checkpoint retained");
230
+ await fsp.unlink(file);
231
+ console.log("BACKUP COMPLETE: "+packs+" packs, "+segments+" segments, "+fmt(bytes)+" source content");
232
+ return {packs,segments,sourceBytes:bytes,approxWorkerRequests:2*packs+1};
233
+ }catch(err){
234
+ // Preserve partial Google session and checkpoint; do NOT delete the pack.
235
+ console.error("Backup interrupted. State kept at "+file);
236
+ throw err;
237
+ }finally{await unlock();}
238
+ }
239
+ function safeTarget(root,relative){
240
+ if(typeof relative!=="string"||relative.includes("\\")||relative.includes("\0")||
241
+ relative.startsWith("/")||relative.split("/").some(s=>s===".."||s==="."||!s))
242
+ throw Error("Unsafe backup entry path");
243
+ const target=path.resolve(root,...relative.split("/"));
244
+ if(!target.startsWith(root+path.sep))throw Error("Restore path escapes root");
245
+ return target;
246
+ }
247
+ async function restore(destination){
248
+ if(!/^tc_live_[0-9a-f]{64}$/.test(TOKEN))throw Error("Set TENCLOUD_API_KEY");
249
+ const dir=path.resolve(destination);
250
+ await fsp.mkdir(dir,{recursive:true});
251
+ if((await fsp.readdir(dir)).length)throw Error("Restore destination must be empty");
252
+ const packs=[];let cursor=0;
253
+ while(true){
254
+ const suffix="?limit=200"+(cursor?"&before="+cursor:"");
255
+ const r=await api("/v1/packs"+suffix);
256
+ packs.push(...r.packs);
257
+ if(!r.nextBefore||r.packs.length<200)break;
258
+ cursor=r.nextBefore;
259
+ }
260
+ packs.sort((a,b)=>a.created_at-b.created_at||a.id.localeCompare(b.id));
261
+ let restored=0;
262
+ for(const [n,pack] of packs.entries()){
263
+ const resp=await fetch(API+"/v1/packs/"+pack.id+"/download",{
264
+ headers:{Authorization:"Bearer "+TOKEN},signal:AbortSignal.timeout(4*60*60*1000)});
265
+ if(!resp.ok||!resp.body)throw Error("Pack "+pack.id+" download HTTP "+resp.status);
266
+ const reader=resp.body[Symbol.asyncIterator]();let carry=Buffer.alloc(0);
267
+ async function take(size){
268
+ const parts=[];let got=0;
269
+ while(got<size){
270
+ if(carry.length){
271
+ const take=carry.subarray(0,Math.min(size-got,carry.length));
272
+ parts.push(take);carry=carry.subarray(take.length);got+=take.length;continue;
273
+ }
274
+ const item=await reader.next();if(item.done)throw Error("Unexpected end of packed backup");
275
+ carry=Buffer.from(item.value);
276
+ }
277
+ return Buffer.concat(parts,size);
278
+ }
279
+ const head=await take(16),h=parseHeader(head),idx=parseManifest(head,await take(h.zipLen));
280
+ let streamed=0;
281
+ for(const entry of idx.entries){
282
+ const target=safeTarget(dir,entry.p);
283
+ await fsp.mkdir(path.dirname(target),{recursive:true});
284
+ let handle;
285
+ try{handle=await fsp.open(target,"r+");}catch(e){if(e.code!=="ENOENT")throw e;handle=await fsp.open(target,"w+");}
286
+ try{
287
+ for(let offset=0;offset<entry.n;){
288
+ const size=Math.min(CHUNK,entry.n-offset);
289
+ const bytes=await take(size);
290
+ let saved=0;
291
+ while(saved<bytes.length){
292
+ const res=await handle.write(bytes,saved,bytes.length-saved,entry.o+offset+saved);
293
+ if(!res.bytesWritten)throw Error("Unable to persist restored file");
294
+ saved+=res.bytesWritten;
295
+ }
296
+ offset+=bytes.length;streamed+=bytes.length;
297
+ }
298
+ await handle.utimes(new Date(entry.m),new Date(entry.m)).catch(()=>{});
299
+ }finally{await handle.close();}
300
+ }
301
+ if(streamed!==idx.sourceBytes)throw Error("Restored pack payload did not match index");
302
+ restored+=idx.entries.length;
303
+ console.log("Restored "+(n+1)+"/"+packs.length+": "+idx.entries.length+" segments");
304
+ }
305
+ console.log("RESTORE COMPLETE: "+restored+" file segments from "+packs.length+" packs.");
306
+ }
307
+ async function main(){
308
+ const [action,target]=process.argv.slice(2);
309
+ if(!target||!["backup","restore"].includes(action))
310
+ throw Error("Usage: node TenCloud_BackupClient_v27.cjs backup|restore PATH");
311
+ await (action==="backup"?backup(target):restore(target));
312
+ }
313
+ if(require.main===module)main().catch(err=>{
314
+ console.error("TenCloud operation failed: "+err.message);process.exitCode=1;
315
+ });
316
+ module.exports={backup,restore,sendDirect,queryGoogle,stateFile,loadState,fingerprint,safeTarget,main};
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ // TenCloud Pack V1: bounded-memory local plan/reader. No Worker ever sees file bytes.
3
+ const fs=require("node:fs"),fsp=require("node:fs/promises"),path=require("node:path"),zlib=require("node:zlib");
4
+ const MAX_PACK=64*1024*1024*1024;
5
+ const MAX_SEGMENTS=10_000,MAX_MANIFEST=2*1024*1024;
6
+ const MAX_DATA=MAX_PACK-MAX_MANIFEST-16;
7
+ const HEADER_MAGIC="TENPACK1";
8
+ async function* walk(root,rel=""){
9
+ if(!rel){
10
+ const st=await fsp.lstat(root);
11
+ if(st.isSymbolicLink())throw Error("Cannot upload symbolic-link source");
12
+ if(st.isFile()){
13
+ yield {name:path.basename(root),size:st.size,mtime:Math.floor(st.mtimeMs)};
14
+ return;
15
+ }
16
+ if(!st.isDirectory())throw Error("Source must be a regular file or directory");
17
+ }
18
+ const entries=await fsp.readdir(path.join(root,rel),{withFileTypes:true});
19
+ entries.sort((a,b)=>a.name.localeCompare(b.name));
20
+ for(const e of entries){
21
+ const name=path.join(rel,e.name);
22
+ if(e.isSymbolicLink())continue;
23
+ if(e.isDirectory())yield* walk(root,name);
24
+ else if(e.isFile()){
25
+ const st=await fsp.stat(path.join(root,name));
26
+ if(st.size>Number.MAX_SAFE_INTEGER)throw Error("Oversized input file: "+name);
27
+ const forward=name.split(path.sep).join("/");
28
+ if(Buffer.byteLength(forward,"utf8")>2048)throw Error("Path too long for backup pack");
29
+ yield {name:forward,size:st.size,mtime:Math.floor(st.mtimeMs)};
30
+ }
31
+ }
32
+ }
33
+ function serialize(entries,sourceBytes){
34
+ const manifest=Buffer.from(JSON.stringify({v:1,sourceBytes,entries}),"utf8");
35
+ const zip=zlib.gzipSync(manifest,{level:6});
36
+ if(zip.byteLength>MAX_MANIFEST)throw Error("Compressed index too large");
37
+ const header=Buffer.alloc(16);
38
+ header.write(HEADER_MAGIC,0,8,"ascii");
39
+ header.writeUInt32LE(zip.byteLength,8);
40
+ header.writeUInt32LE(entries.length,12);
41
+ return Buffer.concat([header,zip]);
42
+ }
43
+ async function* plans(root,{maxPackBytes=MAX_PACK,maxSegments=MAX_SEGMENTS}={}){
44
+ if(maxPackBytes>MAX_PACK||maxPackBytes<2*1024*1024||!Number.isSafeInteger(maxPackBytes))
45
+ throw Error("Pack size must be 2 MiB–64 GiB");
46
+ if(maxSegments>MAX_SEGMENTS||maxSegments<1)throw Error("Invalid pack entry bound");
47
+ const payloadMax=maxPackBytes-MAX_MANIFEST-16;
48
+ let entries=[],payload=0,packs=0;
49
+ function take(){
50
+ if(!entries.length)return null;
51
+ const header=serialize(entries,payload);
52
+ const plan={sequence:packs++,entries,header,payloadBytes:payload,
53
+ totalBytes:header.length+payload};
54
+ if(plan.totalBytes>maxPackBytes)throw Error("Planned pack unexpectedly exceeds configured size");
55
+ entries=[];payload=0;
56
+ return plan;
57
+ }
58
+ for await(const file of walk(root)){
59
+ let pos=0;
60
+ while(pos<file.size||file.size===0&&pos===0){
61
+ if(entries.length>=maxSegments||payload>=payloadMax){
62
+ const p=take();if(p)yield p;
63
+ }
64
+ const available=payloadMax-payload;
65
+ const n=file.size===0?0:Math.min(file.size-pos,available);
66
+ if(n<=0&&file.size>0){const p=take();if(p)yield p;continue;}
67
+ entries.push({p:file.name,o:pos,n,s:file.size,m:file.mtime,d:payload});
68
+ payload+=n;pos+=n;
69
+ if(file.size===0)break;
70
+ // Keep compressed header strictly bounded, not one D1 row per file.
71
+ if(entries.length%1000===0){
72
+ const zipped=serialize(entries,payload);
73
+ if(zipped.length>MAX_MANIFEST+16)throw Error("Index unexpectedly overflowed");
74
+ }
75
+ }
76
+ }
77
+ const last=take();if(last)yield last;
78
+ }
79
+ async function readSlice(root,plan,start,len){
80
+ if(!Number.isSafeInteger(start)||start<0||!Number.isSafeInteger(len)||len<0||start+len>plan.totalBytes)throw Error("Pack offset out of range");
81
+ const sourceRoot=(await fsp.lstat(root)).isFile()?path.dirname(root):root;
82
+ const output=Buffer.allocUnsafe(len);
83
+ let cursor=start,pos=0;
84
+ if(cursor<plan.header.byteLength){
85
+ const amount=Math.min(len,plan.header.length-cursor);
86
+ plan.header.copy(output,0,cursor,cursor+amount);
87
+ cursor+=amount;pos+=amount;
88
+ }
89
+ const payloadOffset=plan.header.length;
90
+ while(pos<len){
91
+ const relative=cursor-payloadOffset;
92
+ const entry=plan.entries.find(e=>e.n>0&&relative>=e.d&&relative<e.d+e.n);
93
+ if(!entry)throw Error("Gap in backup pack layout");
94
+ const inside=relative-entry.d,count=Math.min(len-pos,entry.n-inside);
95
+ const filePath=path.resolve(sourceRoot,...entry.p.split("/"));
96
+ if(!filePath.startsWith(path.resolve(sourceRoot)+path.sep))throw Error("Unexpected path escape");
97
+ const verified=await fsp.lstat(filePath);
98
+ if(!verified.isFile()||verified.isSymbolicLink()||verified.size!==entry.s||
99
+ Math.floor(verified.mtimeMs)!==entry.m)
100
+ throw Error("Backup source file changed after scanning: "+entry.p);
101
+ const fd=await fsp.open(filePath,"r");
102
+ try{
103
+ let n=0;while(n<count){
104
+ const r=await fd.read(output,pos+n,count-n,entry.o+inside+n);
105
+ if(r.bytesRead<=0)throw Error("File changed while uploading: "+entry.p);
106
+ n+=r.bytesRead;
107
+ }
108
+ }finally{await fd.close();}
109
+ cursor+=count;pos+=count;
110
+ }
111
+ return output;
112
+ }
113
+ function parseHeader(buf){
114
+ if(buf.length<16||buf.toString("ascii",0,8)!==HEADER_MAGIC)throw Error("Not a TenCloud Pack V1 file");
115
+ const zipLen=buf.readUInt32LE(8),files=buf.readUInt32LE(12);
116
+ if(zipLen<2||zipLen>MAX_MANIFEST||files<1||files>MAX_SEGMENTS)throw Error("Invalid pack header");
117
+ return {zipLen,files};
118
+ }
119
+ function parseManifest(header,zip){
120
+ const z=parseHeader(header);
121
+ if(zip.length!==z.zipLen)throw Error("Manifest truncated");
122
+ const decompressed=zlib.gunzipSync(zip,{maxOutputLength:12*1024*1024});
123
+ const json=JSON.parse(decompressed.toString("utf8"));
124
+ if(json.v!==1||!Array.isArray(json.entries)||json.entries.length!==z.files)throw Error("Invalid pack index");
125
+ let cursor=0;
126
+ for(const e of json.entries){
127
+ if(typeof e.p!=="string"||!Number.isSafeInteger(e.n)||!Number.isSafeInteger(e.s)||!Number.isSafeInteger(e.o)||!Number.isSafeInteger(e.d)||
128
+ e.n<0||e.o<0||e.d!==cursor||e.o+e.n>e.s)throw Error("Invalid pack range record");
129
+ cursor+=e.n;
130
+ }
131
+ if(cursor!==json.sourceBytes)throw Error("Pack payload/index lengths differ");
132
+ return json;
133
+ }
134
+ module.exports={walk,plans,readSlice,serialize,parseHeader,parseManifest,MAX_PACK,MAX_SEGMENTS,MAX_MANIFEST};
package/lib/sdk.cjs ADDED
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ /** TenCloud JavaScript SDK, Node 22+, uses the same resumable CLI engine. */
3
+ const fs=require("node:fs/promises"),os=require("node:os"),path=require("node:path");
4
+ const {spawn}=require("node:child_process");
5
+ const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
6
+ class TenCloudClient{
7
+ constructor(options={}){
8
+ this.apiUrl=options.apiUrl||"https://api.cesiummc.space";
9
+ this.credentialsDirectory=options.credentialsDirectory||path.join(os.homedir(),".tencloud");
10
+ this.credentialsPath=path.join(this.credentialsDirectory,"credentials.json");
11
+ }
12
+ async _api(endpoint,options={}){
13
+ const response=await fetch(this.apiUrl+endpoint,{
14
+ method:options.method||"GET",headers:{accept:"application/json",...(options.body?{"content-type":"application/json"}:{}),...(options.token?{authorization:"Bearer "+options.token}:{})},
15
+ body:options.body?JSON.stringify(options.body):undefined,cache:"no-store",signal:options.signal||AbortSignal.timeout(40000)
16
+ });
17
+ const data=await response.json().catch(()=>({error:"Invalid JSON response"}));
18
+ return {response,data};
19
+ }
20
+ async getToken(){
21
+ if(process.env.TENCLOUD_API_KEY)return process.env.TENCLOUD_API_KEY;
22
+ try{
23
+ const saved=JSON.parse(await fs.readFile(this.credentialsPath,"utf8"));
24
+ return saved.api===this.apiUrl&&/^tc_live_[0-9a-f]{64}$/.test(saved.token||"")?saved.token:null;
25
+ }catch{return null;}
26
+ }
27
+ async _saveToken(token){
28
+ await fs.mkdir(this.credentialsDirectory,{recursive:true,mode:0o700});
29
+ const temp=this.credentialsPath+"."+process.pid+".tmp";
30
+ await fs.writeFile(temp,JSON.stringify({api:this.apiUrl,token},null,2),{mode:0o600,flag:"wx"});
31
+ await fs.rename(temp,this.credentialsPath);
32
+ if(process.platform!=="win32")await fs.chmod(this.credentialsPath,0o600);
33
+ }
34
+ async authenticate({onChallenge,signal}={}){
35
+ const start=await this._api("/v1/device/start",{method:"POST",body:{label:os.hostname().slice(0,40)||"TenCloud SDK"},signal});
36
+ if(!start.response.ok)throw Error("Device authorization failed: "+(start.data.error||start.response.status));
37
+ const challenge={
38
+ verificationUri:start.data.verification_uri,userCode:start.data.user_code,
39
+ expiresIn:start.data.expires_in
40
+ };
41
+ if(onChallenge)await onChallenge(challenge);
42
+ else console.log("Open "+challenge.verificationUri+" and enter "+challenge.userCode);
43
+ const expires=Date.now()+start.data.expires_in*1000;
44
+ const interval=Math.max(7000,start.data.interval*1000);
45
+ while(Date.now()<expires){
46
+ if(signal?.aborted)throw Error("Authorization cancelled");
47
+ await sleep(interval);
48
+ const {response,data}=await this._api("/v1/device/token",{method:"POST",body:{device_code:start.data.device_code},signal});
49
+ if(response.ok&&data.access_token){
50
+ const me=await this._api("/v1/me",{token:data.access_token,signal});
51
+ if(!me.response.ok)throw Error("Issued credential failed validation");
52
+ await this._saveToken(data.access_token);
53
+ return me.data;
54
+ }
55
+ if(["authorization_pending","slow_down"].includes(data.error))continue;
56
+ throw Error("Device authorization rejected: "+(data.error||response.status));
57
+ }
58
+ throw Error("Device authorization expired");
59
+ }
60
+ async account(){
61
+ const token=await this.getToken();
62
+ if(!token)throw Error("Not authenticated. Call authenticate()");
63
+ const {response,data}=await this._api("/v1/me",{token});
64
+ if(!response.ok)throw Error("Invalid or revoked TenCloud credential");
65
+ return data;
66
+ }
67
+ async _run(command,target,{onOutput,signal}={}){
68
+ if(!target)throw Error("Expected a local file or folder path");
69
+ if(!await this.getToken())throw Error("Not authenticated. Call authenticate()");
70
+ const argv=[path.join(__dirname,"..","bin","tencloud.cjs"),command,path.resolve(target)];
71
+ return new Promise((resolve,reject)=>{
72
+ const child=spawn(process.execPath,argv,{env:{...process.env,TENCLOUD_API_URL:this.apiUrl,TENCLOUD_CONFIG_DIR:this.credentialsDirectory},windowsHide:true,stdio:["ignore","pipe","pipe"]});
73
+ let output="",errors="",settled=false;
74
+ const report=(value,isError)=>{
75
+ if(isError)errors+=value;
76
+ else output+=value;
77
+ if(onOutput)onOutput(value,isError?"stderr":"stdout");
78
+ };
79
+ child.stdout.on("data",data=>report(data.toString("utf8"),false));
80
+ child.stderr.on("data",data=>report(data.toString("utf8"),true));
81
+ const abort=()=>child.kill();
82
+ if(signal){if(signal.aborted)abort();else signal.addEventListener("abort",abort,{once:true});}
83
+ child.on("error",error=>{if(!settled){settled=true;reject(error);}});
84
+ child.on("close",code=>{
85
+ if(signal)signal.removeEventListener("abort",abort);
86
+ if(settled)return;settled=true;
87
+ if(code===0)resolve({ok:true,output});
88
+ else reject(Error("TenCloud "+command+" failed: "+(errors||output||"process exited "+code).trim().slice(-1000)));
89
+ });
90
+ });
91
+ }
92
+ upload(localPath,options){return this._run("upload",localPath,options);}
93
+ uploadFile(localPath,options){return this.upload(localPath,options);}
94
+ uploadFolder(localPath,options){return this.upload(localPath,options);}
95
+ download(outputFolder,options){return this._run("download",outputFolder,options);}
96
+ async logout(){
97
+ await fs.rm(this.credentialsPath,{force:true});
98
+ return {localCredentialRemoved:true,serverCredentialStillValid:true};
99
+ }
100
+ }
101
+ const CesiumCloudClient=TenCloudClient;
102
+ module.exports={CesiumCloudClient,TenCloudClient};
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
- {
2
- "name": "cesiumcloud",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "cesiumcloud",
3
+ "version": "0.1.0",
4
+ "description": "CesiumCloud secure storage CLI and JavaScript SDK, compatible with existing TenCloud accounts.",
5
+ "license": "UNLICENSED",
6
+ "main": "lib/sdk.cjs",
7
+ "bin": {
8
+ "cesiumcloud": "bin/cesiumcloud.cjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "lib"
16
+ ],
17
+ "os": [
18
+ "win32",
19
+ "darwin",
20
+ "linux"
21
+ ],
22
+ "keywords": [
23
+ "cesiumcloud",
24
+ "cloud",
25
+ "storage",
26
+ "backup"
27
+ ]
28
+ }