dewctl 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dewdrop Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # dewctl
2
+
3
+ The Dewdrop CLI builds your three.js game, uploads it to Dewdrop as you work,
4
+ and publishes it for players.
5
+
6
+ ## Start a game
7
+
8
+ In a new, empty folder, with Node.js 24 or newer:
9
+
10
+ ```sh
11
+ npx dewctl init
12
+ npm install
13
+ npx dewctl login
14
+ npx dewctl dev --watch
15
+ ```
16
+
17
+ `dev --watch` prints a link to play your game and uploads it again each time
18
+ you save. When it's ready, `npx dewctl publish` puts it in front of players.
19
+
20
+ ## Docs
21
+
22
+ The development flow, every command, `dewdrop.json` and troubleshooting are in
23
+ the docs: https://alpha.dewdrop.world/docs
24
+
25
+ ## License
26
+
27
+ MIT
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>My game</title>
7
+ </head>
8
+ <body>
9
+ <script type="module" src="/src/main.js"></script>
10
+ </body>
11
+ </html>
@@ -0,0 +1,34 @@
1
+ // Your game. Save a change while `dewctl dev --watch` runs and the game on Dewdrop updates.
2
+ import * as THREE from 'three';
3
+
4
+ const scene=new THREE.Scene();
5
+ scene.background=new THREE.Color('#142232');
6
+ const camera=new THREE.PerspectiveCamera(55,innerWidth/innerHeight,0.1,100);
7
+ camera.position.set(0,0,5);
8
+ const renderer=new THREE.WebGLRenderer();
9
+ // On Dewdrop, describe the look of your whole game here…
10
+ renderer.prompt='A polished turquoise cube floating in a dark blue studio, soft cinematic lighting, clean stylized 3D game art.';
11
+ renderer.setSize(innerWidth,innerHeight);
12
+ document.body.appendChild(renderer.domElement);
13
+ const cube=new THREE.Mesh(new THREE.BoxGeometry(1.5,1.5,1.5),new THREE.MeshStandardMaterial({color:'#4edbb1',roughness:0.65}));
14
+ // …and what this mesh is.
15
+ cube.userData.description='A polished turquoise cube';
16
+ scene.add(cube);
17
+ scene.add(new THREE.AmbientLight(0xffffff,1));
18
+ const light=new THREE.DirectionalLight(0xffffff,2);
19
+ light.position.set(3,4,5);
20
+ scene.add(light);
21
+ const heading=document.createElement('h1');
22
+ heading.textContent='Hello from your Dewdrop game';
23
+ heading.style.cssText='position:absolute;top:20px;left:24px;color:white;font:24px sans-serif;';
24
+ document.body.appendChild(heading);
25
+ addEventListener('resize',()=>{
26
+ camera.aspect=innerWidth/innerHeight;
27
+ camera.updateProjectionMatrix();
28
+ renderer.setSize(innerWidth,innerHeight);
29
+ });
30
+ renderer.setAnimationLoop(time=>{
31
+ cube.rotation.x=time/2400;
32
+ cube.rotation.y=time/1800;
33
+ renderer.render(scene,camera);
34
+ });
@@ -0,0 +1,3 @@
1
+ import dew from 'dewdrop-sdk/vite';
2
+
3
+ export default { plugins: [dew()] };
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "dewctl",
3
+ "version": "0.5.0",
4
+ "description": "Build and publish three.js games on Dewdrop.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "dewctl": "src/main.js" },
8
+ "files": ["src", "examples", "README.md"],
9
+ "engines": { "node": ">=24" },
10
+ "scripts": { "test": "node --test tests/*.test.js" }
11
+ }
package/src/client.js ADDED
@@ -0,0 +1,95 @@
1
+ // Requests to Dewdrop, only ever to the address in dewdrop.json, signed in with the session login stored.
2
+ import {RUN} from './command.js';
3
+ export class ApiError extends Error {
4
+ constructor(status,message){super(message);this.status=status;}
5
+ }
6
+ // A member's address becomes a path segment; refuse anything that could reach another route.
7
+ const EMAIL=/^[^\s@/?#%]+@[^\s@/?#%]+\.[^\s@/?#%]+$/;
8
+ export function sleep(ms,signal) {
9
+ return new Promise((resolve,reject)=>{
10
+ const timer=setTimeout(done,ms);
11
+ function done(){signal?.removeEventListener('abort',cancel);resolve();}
12
+ function cancel(){clearTimeout(timer);reject(signal.reason);}
13
+ if(signal?.aborted)cancel();else signal?.addEventListener('abort',cancel,{once:true});
14
+ });
15
+ }
16
+ // An upload is sent in parts of at most this many bytes.
17
+ export const PART_BYTES=32*1024*1024;
18
+ export class Client {
19
+ constructor(origin,credentials,{fetcher=fetch,pollMs=1000,timeoutMs=180000}={}){this.origin=origin;this.credentials=credentials;this.fetcher=fetcher;this.pollMs=pollMs;this.timeoutMs=timeoutMs;}
20
+ // A request signed in with `token`. When Dewdrop can't be reached the failure is marked transient, which a watcher
21
+ // or a sign-in waits out; a refused token asks for a new sign-in, whatever Dewdrop's words.
22
+ async request(path,{method='GET',body,token,signal,type}={}) {
23
+ if(!path.startsWith('/v1/'))throw new Error('Invalid API path.');
24
+ const bytes=Buffer.isBuffer(body);
25
+ let response;
26
+ try {
27
+ response=await this.fetcher(this.origin+path,{method,redirect:'error',signal:signal?AbortSignal.any([signal,AbortSignal.timeout(120000)]):AbortSignal.timeout(120000),headers:{...(token?{authorization:`Bearer ${token}`}:{}),...(body!==undefined?{'content-type':type??(bytes?'application/octet-stream':'application/json')}:{})},body:body===undefined?undefined:bytes||typeof body==='string'?body:JSON.stringify(body)});
28
+ } catch(error) {
29
+ if(signal?.aborted)throw error;
30
+ const reason=error.name==='TimeoutError'?'no answer in two minutes':error.cause?.code??error.cause?.message??error.message;
31
+ throw Object.assign(new Error(`Couldn't reach ${this.origin} (${reason}). Check your connection.`),{transient:true});
32
+ }
33
+ const contentType=response.headers.get('content-type')??'';
34
+ if(!contentType.includes('application/json'))throw new ApiError(response.status,`${this.origin} didn't answer as Dewdrop does (HTTP ${response.status}). Try again in a moment.`);
35
+ const data=await response.json();
36
+ if(response.status===401&&token)throw new ApiError(401,`Your sign-in has ended: it expired or was signed out. Run ${RUN} login.`);
37
+ if(!response.ok)throw new ApiError(response.status,data.error??`HTTP ${response.status}`);
38
+ return data;
39
+ }
40
+ // The session this computer holds, or why there is none to use.
41
+ session() {
42
+ const current=this.credentials.stored();
43
+ if(!current)throw new ApiError(401,`You're not signed in. Run ${RUN} login.`);
44
+ if(typeof current.expires==='number'&&Date.now()>=current.expires)throw new ApiError(401,`Your sign-in expired on ${new Date(current.expires).toISOString().slice(0,10)}. Run ${RUN} login.`);
45
+ return current;
46
+ }
47
+ async api(path,options={}){return this.request(path,{...options,token:this.session().token});}
48
+ game(game,signal){return this.api(`/v1/games/${game}`,{signal});}
49
+ releases(game){return this.api(`/v1/games/${game}/releases`);}
50
+ // Makes the game this developer's if it is new, and confirms it is theirs if not: asked before anything is built, so
51
+ // a name someone else has, or a sign-in that has ended, is known at once.
52
+ async claim(game,signal) {
53
+ try { return await this.api(`/v1/games/${game}`,{method:'PUT',body:{},signal}); }
54
+ catch(error) {
55
+ if(error.status===409&&/owns this game name/.test(error.message))throw new ApiError(409,`Someone else already has a game named ${game}. Choose another name: change "game" in dewdrop.json.`);
56
+ throw error;
57
+ }
58
+ }
59
+ // Uploads a build of a claimed game under its id, in parts of PART_BYTES, then completes it with the number of
60
+ // parts; Dewdrop must confirm the same id.
61
+ async upload(game,bundle,signal) {
62
+ const parts=Math.max(1,Math.ceil(bundle.body.length/PART_BYTES));
63
+ for(let index=0;index<parts;index++) {
64
+ await this.api(`/v1/games/${game}/releases/${bundle.id}/parts/${index}`,{method:'PUT',body:bundle.body.subarray(index*PART_BYTES,(index+1)*PART_BYTES),signal});
65
+ }
66
+ const result=await this.api(`/v1/games/${game}/releases/${bundle.id}`,{method:'POST',body:{parts},signal});
67
+ if(result.release!==bundle.id)throw new Error('Dewdrop stored a different release than was uploaded, so nothing was changed. Try again.');
68
+ return result;
69
+ }
70
+ activate(game,release,signal){return this.point(game,'dev',release,signal);}
71
+ stop(game,signal){return this.point(game,'dev',null,signal);}
72
+ publish(game,release,signal){return this.point(game,'live',release,signal);}
73
+ // Points a version at a release, then follows the game until that version is ready, fails, or somebody points it
74
+ // elsewhere. The PUT answers with the game, so it is the first look.
75
+ async point(game,version,release,signal) {
76
+ let state=await this.api(`/v1/games/${game}/${version}`,{method:'PUT',body:{release},signal});
77
+ if(release===null&&version==='live')return state;
78
+ const doing=release===null?'Stopping the dev version':version==='dev'?'Starting the dev version':'Publishing',deadline=Date.now()+this.timeoutMs;
79
+ for(;;) {
80
+ const current=state[version],now=current.release?.slice(0,12);
81
+ if(current.release!==release)throw new Error(version==='live'?`The published version changed meanwhile: it is now ${now??'unpublished'}.`:`The dev version changed meanwhile: it now ${now?`runs ${now}`:'is stopped'}.`);
82
+ if(current.error)throw new Error(current.error);
83
+ if(current.ready)return state;
84
+ if(Date.now()>=deadline)throw new Error(`${doing} is taking longer than three minutes. Run ${RUN} status to follow it.`);
85
+ await sleep(this.pollMs,signal);
86
+ state=await this.game(game,signal);
87
+ }
88
+ }
89
+ // Sets the game's thumbnail to the image, sent as itself under its type.
90
+ thumbnail(game,bytes,type,signal){return this.api(`/v1/games/${game}/thumbnail`,{method:'PUT',body:bytes,type,signal});}
91
+ async member(game,email,on,signal) {
92
+ if(!EMAIL.test(email??''))throw new Error("Pass the developer's email address, for example dev@example.com.");
93
+ return this.api(`/v1/games/${game}/members/${email}`,on?{method:'PUT',body:{},signal}:{method:'DELETE',signal});
94
+ }
95
+ }
package/src/command.js ADDED
@@ -0,0 +1,2 @@
1
+ // How the developer runs this CLI, for the commands its messages suggest: through npx when npx started it, else by name.
2
+ export const RUN=process.env.npm_command==='exec'?'npx dewctl':'dewctl';
@@ -0,0 +1,92 @@
1
+ // The developer's sign-in, kept in an owner-only JSON file under ~/.dewdrop. A login's write and a logout's
2
+ // compare-and-clear exclude each other across processes through a lock beside the file, held for the few
3
+ // milliseconds either takes; a lock nobody releases is reported. A session past its expiry reads as absent, yet a
4
+ // logout that saw its token still clears it.
5
+ import {mkdirSync, openSync, closeSync, fstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, constants, lstatSync, existsSync} from 'node:fs';
6
+ import {join, dirname, resolve} from 'node:path';
7
+ import {homedir} from 'node:os';
8
+ import {createHash,randomBytes} from 'node:crypto';
9
+
10
+ const LOCK_TRIES=100,LOCK_WAIT_MS=20;
11
+
12
+ export class Credentials {
13
+ constructor(origin,{file=process.env.DEWDROP_CREDENTIALS_FILE}={}) {
14
+ this.origin=origin;
15
+ this.file=resolve(file||join(homedir(),'.dewdrop',`${origin.replace(/[^a-zA-Z0-9.-]+/g,'_').slice(0,40)}-${createHash('sha256').update(origin).digest('hex').slice(0,16)}.json`));
16
+ }
17
+ fileCheck(path,fd) {
18
+ const stat=fd===undefined?lstatSync(path):fstatSync(fd);
19
+ if(!stat.isFile() || (stat.mode&0o077)!==0 || process.getuid && stat.uid!==process.getuid())throw new Error(`${path} holds your sign-in, so it must be a file only you can read. Run: chmod 600 ${path}`);
20
+ }
21
+ // The folder the sign-in is kept in, made if missing and checked private to this user; login checks it before it
22
+ // asks anyone to approve a link, so a sign-in is never approved only to find nowhere to keep it.
23
+ prepare() {
24
+ const dir=dirname(this.file);
25
+ mkdirSync(dir,{recursive:true,mode:0o700});
26
+ const parent=lstatSync(dir);
27
+ if(!parent.isDirectory() || (parent.mode&0o077)!==0 || process.getuid && parent.uid!==process.getuid())throw new Error(`${dir} holds your sign-in, so it must be a folder only you can open. Run: chmod 700 ${dir}`);
28
+ if(existsSync(this.file))this.fileCheck(this.file);
29
+ return dir;
30
+ }
31
+ // The stored session regardless of expiry, or null when none is kept. A file holding another address's session, or
32
+ // one anyone but the owner can read, is refused rather than used. `clear` compares against this so a session that
33
+ // expired between a logout's read and its request can still be removed.
34
+ stored() {
35
+ let fd;
36
+ try {fd=openSync(this.file,constants.O_RDONLY|constants.O_NOFOLLOW);}
37
+ catch(error){if(error.code==='ENOENT')return null;throw error;}
38
+ let row;
39
+ try {this.fileCheck(this.file,fd);row=JSON.parse(readFileSync(fd,'utf8'));}
40
+ finally{closeSync(fd);}
41
+ if(row.origin!==this.origin)throw new Error(`${this.file} holds a sign-in for ${row.origin}, not ${this.origin}.`);
42
+ return row.session??null;
43
+ }
44
+ // The stored session, or null when none is kept or the kept one has passed its expiry.
45
+ read() {
46
+ const session=this.stored();
47
+ if(session&&typeof session.expires==='number'&&Date.now()>=session.expires)return null;
48
+ return session;
49
+ }
50
+ lockPath(){return `${this.file}.lock`;}
51
+ // Runs `fn` holding the file's lock, so no other dewctl process writes or clears it meanwhile.
52
+ locked(fn) {
53
+ mkdirSync(dirname(this.file),{recursive:true,mode:0o700});
54
+ const lock=this.lockPath();
55
+ let fd;
56
+ for(let attempt=0;fd===undefined;attempt++) {
57
+ try{fd=openSync(lock,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL,0o600);}
58
+ catch(error){
59
+ if(error.code!=='EEXIST')throw error;
60
+ if(attempt>=LOCK_TRIES)throw new Error(`Another dewctl is using your sign-in right now. If none is running, remove ${lock}.`);
61
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,LOCK_WAIT_MS);
62
+ }
63
+ }
64
+ try{return fn();}
65
+ finally{closeSync(fd);unlinkSync(lock);}
66
+ }
67
+ write(session) {
68
+ const dir=this.prepare();
69
+ this.locked(()=>{
70
+ if(existsSync(this.file))this.fileCheck(this.file);
71
+ const temp=join(dir,`.session-${randomBytes(8).toString('hex')}`);
72
+ writeFileSync(temp,JSON.stringify({origin:this.origin,session}),{flag:'wx',mode:0o600});
73
+ renameSync(temp,this.file);
74
+ });
75
+ }
76
+ // Removes the stored session only while the file still holds what the caller saw — the token `expected`, or
77
+ // nothing when `expected` is null — under the file's lock, so a logout that raced a fresh login leaves the new
78
+ // session alone. Answers whether the file is now clear of what the caller saw.
79
+ clear(expected) {
80
+ if(expected!==null&&typeof expected!=='string')throw new Error('clear takes the token seen, or null for none.');
81
+ // No credential directory holds nothing: nothing to lock or remove.
82
+ if(!existsSync(dirname(this.file)))return expected===null;
83
+ return this.locked(()=>{
84
+ const raw=this.stored();
85
+ // A named token clears while the file still holds it, expiry aside. `null` — what logout passes when it read
86
+ // no usable session — clears an absent or expired one, but keeps a fresh login stored meanwhile.
87
+ if(expected===null?(raw!==null&&!(typeof raw.expires==='number'&&Date.now()>=raw.expires)):(!raw||raw.token!==expected))return false;
88
+ try{unlinkSync(this.file);}catch(e){if(e.code!=='ENOENT')throw e;}
89
+ return true;
90
+ });
91
+ }
92
+ }
package/src/login.js ADDED
@@ -0,0 +1,35 @@
1
+ // Signing in by link. The CLI makes its own sign-in token and prints a link carrying the token's hash, not the token,
2
+ // and this computer's name; the developer opens it, checks that it shows the code printed here, and approves it.
3
+ // Meanwhile the CLI asks Dewdrop, with the token, whose it is, which Dewdrop answers once the link is approved.
4
+ import {createHash,randomBytes} from 'node:crypto';
5
+ import {hostname} from 'node:os';
6
+ import {sleep} from './client.js';
7
+ import {RUN} from './command.js';
8
+
9
+ // This computer's name as the link carries it: its host name without a trailing .local, in the plain characters
10
+ // Dewdrop accepts.
11
+ export function machineLabel(name=hostname()) {
12
+ const label=String(name).replace(/\.local$/i,'').replace(/[^\w .@()-]+/g,'-').replace(/^[-\s]+|[-\s]+$/g,'').slice(0,80).trim();
13
+ return label||'computer';
14
+ }
15
+ // A fresh token and the link that asks Dewdrop to approve it, with the code both the page and the terminal show:
16
+ // the first eight hex digits of the token's hash.
17
+ export function signInLink(origin,{token=randomBytes(32).toString('base64url'),name=hostname()}={}) {
18
+ const hash=createHash('sha256').update(token).digest('hex'),label=machineLabel(name);
19
+ return {token,code:`${hash.slice(0,4)}-${hash.slice(4,8)}`.toUpperCase(),label,url:`${origin}/account/cli?h=${hash}&label=${encodeURIComponent(label)}`};
20
+ }
21
+ // The account the token belongs to, once the link is approved. Until then Dewdrop refuses the token (401); a busy or
22
+ // briefly unreachable Dewdrop is waited out too. Anything else is a real failure and ends the wait.
23
+ export async function waitForApproval(client,token,{signal,pollMs=2000,timeoutMs=10*60*1000}={}) {
24
+ const deadline=Date.now()+timeoutMs;
25
+ for(;;) {
26
+ try { return await client.request('/v1/me',{token,signal}); }
27
+ catch(error) {
28
+ if(signal?.aborted)throw signal.reason;
29
+ const waiting=error.status===401||error.status===429||error.status>=500||error.transient;
30
+ if(!waiting)throw error;
31
+ }
32
+ if(Date.now()>=deadline)throw new Error(`Nobody approved the sign-in link in ten minutes. Run ${RUN} login again.`);
33
+ await sleep(pollMs,signal);
34
+ }
35
+ }
package/src/main.js ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ // Dewdrop's developer commands: sign in, connect a directory to a game, then run, publish, share and inspect it.
3
+ import {spawn} from 'node:child_process';
4
+ import {readFileSync} from 'node:fs';
5
+ import {readProject,platformOrigin,initProject,DEWDROP} from './project.js';
6
+ import {Credentials} from './credentials.js';
7
+ import {Client} from './client.js';
8
+ import {deploy,watch,size} from './watch.js';
9
+ import {devPlayUrl} from './play.js';
10
+ import {signInLink,waitForApproval} from './login.js';
11
+ import {RUN} from './command.js';
12
+ import {syncThumbnail} from './thumbnail.js';
13
+
14
+ const HELP=`${RUN} — make three.js games on Dewdrop
15
+
16
+ init [--game NAME] Connect this folder to a game; an empty folder gets the starter game.
17
+ The game is named after the folder unless --game names it
18
+ login Sign in: prints a link to open in your browser and approve
19
+ logout Sign this computer out
20
+ dev [--watch] [--release ID] Build, upload and play the dev version; --watch repeats this on every
21
+ save, --release plays an earlier upload without building
22
+ stop Stop the dev version and disconnect its players; uploads are kept
23
+ publish [--release ID] Publish the dev version, or an earlier upload, for players; uploads
24
+ the thumbnail dewdrop.json names when it has changed
25
+ unpublish Take the game down for players
26
+ share EMAIL Let another developer play and update the dev version
27
+ unshare EMAIL Take that back
28
+ status Show the dev and published versions and where to play them
29
+ releases List your uploads; any id, or its first 8 characters, feeds --release
30
+ --version Show this CLI's version
31
+
32
+ Run the commands in your game's folder: they read the game from its dewdrop.json.
33
+ Docs: ${DEWDROP}/docs
34
+ `;
35
+ // Each command's flags; share and unshare also take one address. Anything else on the line is refused.
36
+ const COMMANDS={init:['--game'],login:[],logout:[],dev:['--watch','--release'],stop:[],publish:['--release'],unpublish:[],share:[],unshare:[],status:[],releases:[]};
37
+ const VALUED=['--game','--release'];
38
+ function options(command,args) {
39
+ const out={};
40
+ for(let i=0;i<args.length;i++) {
41
+ const key=args[i];
42
+ if(COMMANDS[command].includes(key)) {
43
+ if(!VALUED.includes(key))out[key.slice(2)]=true;
44
+ else if(!args[i+1]||args[i+1].startsWith('--'))throw new Error(`${key} needs a value.`);
45
+ else out[key.slice(2)]=args[++i];
46
+ } else if(['share','unshare'].includes(command)&&!key.startsWith('-')&&out.email===undefined)out.email=key;
47
+ else throw new Error(`${command} doesn't take ${key}. Run ${RUN} --help to see what it takes.`);
48
+ }
49
+ if(out.release!==undefined&&!/^[a-f0-9]{8,64}$/.test(out.release))throw new Error(`--release takes a release id, or its first 8 or more characters, from ${RUN} releases.`);
50
+ if(out.watch&&out.release)throw new Error('Choose --watch or --release, not both.');
51
+ return out;
52
+ }
53
+ // Opens the link in this computer's browser. login prints the link too, so a browser that won't start is no failure.
54
+ function open(url) {
55
+ const command=process.platform==='darwin'?'open':'xdg-open';
56
+ const child=spawn(command,[url],{stdio:'ignore',detached:true});
57
+ child.on('error',()=>{});
58
+ child.unref();
59
+ }
60
+ // A browser is opened for a person at this computer's own screen: never over SSH, and on Linux only with a display.
61
+ const atScreen=()=>Boolean(process.stdout.isTTY&&!process.env.SSH_CONNECTION&&!process.env.SSH_TTY&&(process.platform!=='linux'||process.env.DISPLAY||process.env.WAYLAND_DISPLAY));
62
+ // A version as status prints it: its release and whether that is loading, ready or failed, or else whether it stopped.
63
+ function describe(pointer,idle) {
64
+ if(pointer.release)return `${pointer.release.slice(0,12)}, ${pointer.error?`failed: ${pointer.error}`:pointer.ready?'ready':'loading'}`;
65
+ if(pointer.error)return `couldn't stop: ${pointer.error}`;
66
+ return pointer.ready?idle:'stopping';
67
+ }
68
+ // Columns padded to their widest cell, for the lists the CLI prints.
69
+ const columns=rows=>{const widths=rows[0].map((_,i)=>Math.max(...rows.map(row=>String(row[i]).length)));return rows.map(row=>row.map((cell,i)=>i===row.length-1?cell:String(cell).padEnd(widths[i])).join(' ')).join('\n');};
70
+ // A release named by its whole id or its first characters, from the game's uploads.
71
+ async function releaseId(client,game,id) {
72
+ if(id.length===64)return id;
73
+ const matches=(await client.releases(game)).releases.filter(release=>release.sha256.startsWith(id));
74
+ if(matches.length===1)return matches[0].sha256;
75
+ throw new Error(matches.length?`${id} matches ${matches.length} releases; give more of its characters.`:`No upload of ${game} starts with ${id}. Run ${RUN} releases to list them.`);
76
+ }
77
+ async function main() {
78
+ // The paths, permissions and processes the CLI relies on are POSIX ones.
79
+ if(process.platform==='win32')throw new Error('The Dewdrop CLI runs on macOS and Linux. On Windows, run it inside WSL (Windows Subsystem for Linux).');
80
+ const [command,...args]=process.argv.slice(2);
81
+ if(!command||['--help','help','-h'].includes(command)||args.some(arg=>arg==='--help'||arg==='-h')){console.log(HELP);return;}
82
+ if(['--version','-v','version'].includes(command)){console.log(JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version);return;}
83
+ if(!Object.hasOwn(COMMANDS,command))throw new Error(`There's no ${command} command. Run ${RUN} --help to see them.`);
84
+ const flags=options(command,args);
85
+ if(command==='init') {
86
+ const config=initProject(process.cwd(),flags.game);
87
+ const run=RUN;
88
+ if(config.starter)console.log(`Set up ${config.game} here. Next:\n npm install\n ${run} login\n ${run} dev --watch\nThe game is created on Dewdrop the first time you run dev. To try it here without Dewdrop, run npm run dev.`);
89
+ else console.log(`Created dewdrop.json for ${config.game}. Next: ${run} login, then ${run} dev --watch.`);
90
+ if(config.buildScript==='build')console.log('Using your build script (npm run build) to build for Dewdrop. It must build with the Dewdrop plugin (vite build --mode dew); if it doesn\'t, add a build:dew script that does and set "build" in dewdrop.json to ["npm","run","build:dew"].');
91
+ if(!config.starter&&!config.buildScript)console.log('No build:dew or build script is in package.json, so dewdrop.json builds with npm run build:dew. Add that script, building with the Dewdrop plugin (vite build --mode dew), or set "build" in dewdrop.json to your own command.');
92
+ return;
93
+ }
94
+ // Every command but init reads the game from the project's dewdrop.json.
95
+ const project=readProject(process.cwd());
96
+ const origin=platformOrigin();
97
+ const credentials=new Credentials(origin),client=new Client(origin,credentials);
98
+ // The commands that wait on Dewdrop stop waiting at Ctrl-C; the rest end the ordinary way.
99
+ const controller=new AbortController(),{signal}=controller;
100
+ if(['login','stop','unpublish','publish','dev'].includes(command)) {
101
+ process.once('SIGINT',()=>controller.abort(new Error('Stopped.')));
102
+ process.once('SIGTERM',()=>controller.abort(new Error('Stopped.')));
103
+ }
104
+ // login makes a sign-in token and prints the link that approves it; the token is kept once Dewdrop says whose it is.
105
+ if(command==='login') {
106
+ credentials.prepare();
107
+ const link=signInLink(origin);
108
+ console.log(`Open this link to sign in the Dewdrop CLI:\n\n ${link.url}\n\nCheck that the page shows ${link.code}, then choose Sign in the CLI. Waiting…`);
109
+ if(atScreen())open(link.url);
110
+ let me;
111
+ try { me=await waitForApproval(client,link.token,{signal}); }
112
+ catch(error) { throw signal.aborted?new Error('Sign-in cancelled.'):error; }
113
+ credentials.write({token:link.token,email:me.email,expires:me.expires});
114
+ console.log(`Signed in as ${me.username??me.email}.`);
115
+ return;
116
+ }
117
+ if(command==='logout') {
118
+ const session=credentials.read();
119
+ if(session) {
120
+ try { await client.request('/v1/logout',{method:'POST',token:session.token,body:{}}); }
121
+ catch(error) { if(error.status!==401)throw error; }
122
+ }
123
+ // The store is cleared only while it still holds what was read — a login that ran meanwhile stored a newer token, and that one stays.
124
+ if(!credentials.clear(session?.token??null)){console.log(session?'Signed out; a newer sign-in on this computer is kept.':'A sign-in on this computer finished meanwhile and is kept.');return;}
125
+ console.log(session?'Signed out.':'You weren\'t signed in.');
126
+ return;
127
+ }
128
+ const game=project.game;
129
+ if(command==='status') {
130
+ let state;
131
+ try { state=await client.game(game); }
132
+ catch(error) { if(error.status===404)throw new Error(`${game} isn't on Dewdrop yet. Run ${RUN} dev to create it.`); throw error; }
133
+ const url=devPlayUrl(state);
134
+ console.log(`${game}\n Dev version: ${describe(state.dev,'stopped')}${state.dev.release?` — ${url}`:''}\n Published: ${describe(state.live,'not published')}${state.live.release?` — ${state.urls.play}`:''}\n Thumbnail: ${state.urls.thumbnail??'none'}`);
135
+ return;
136
+ }
137
+ if(command==='releases'){
138
+ const {releases}=await client.releases(game);
139
+ if(!releases.length){console.log(`No uploads of ${game} yet. Run ${RUN} dev to make one.`);return;}
140
+ console.log(columns([['RELEASE','UPLOADED (UTC)','SIZE','BY'],...releases.map(release=>[release.sha256.slice(0,12),new Date(release.created).toISOString().slice(0,16).replace('T',' '),size(release.bytes),release.email])]));
141
+ return;
142
+ }
143
+ if(['share','unshare'].includes(command)) {
144
+ if(!flags.email)throw new Error(`${command} needs the developer's email address.`);
145
+ await client.member(game,flags.email,command==='share');
146
+ console.log(command==='share'?`Shared ${game} with ${flags.email}. They can play and update the dev version.`:`Removed ${flags.email} from ${game}.`);
147
+ return;
148
+ }
149
+ if(command==='stop'){
150
+ await client.stop(game,signal);
151
+ console.log(`Stopped ${game}'s dev version. Its uploads are kept; run ${RUN} dev to start it again.`);return;
152
+ }
153
+ if(command==='unpublish'){await client.publish(game,null,signal);console.log(`Unpublished ${game}. Players can no longer open it.`);return;}
154
+ if(command==='publish') {
155
+ const current=await client.game(game,signal);
156
+ const release=flags.release?await releaseId(client,game,flags.release):current.dev.release;
157
+ if(!release)throw new Error(`${game} has no dev version to publish. Run ${RUN} dev first, or publish an earlier upload with --release (see ${RUN} releases).`);
158
+ // The thumbnail goes first, so an image Dewdrop refuses stops the publish before anything changes for players.
159
+ if(project.thumbnail&&await syncThumbnail(project,client,current,signal))console.log(`Uploaded the thumbnail, ${project.thumbnail}.`);
160
+ const state=await client.publish(game,release,signal);
161
+ console.log(`Published ${release.slice(0,12)}. Players can open ${game} at ${state.urls.play}`);return;
162
+ }
163
+ if(flags.release){
164
+ const release=await releaseId(client,game,flags.release);
165
+ const state=await client.activate(game,release,signal);
166
+ console.log(`Playing ${release.slice(0,12)} at ${devPlayUrl(state)}`);return;
167
+ }
168
+ if(flags.watch)await watch(project,client,{signal,reread:()=>readProject(project.root)});
169
+ else if((await deploy(project,client,{signal})).changed)throw new Error(`Files changed during the build, so nothing was uploaded. Run ${RUN} dev again, or ${RUN} dev --watch to rebuild on every save.`);
170
+ }
171
+ main().catch(error=>{console.error(`dewctl: ${error.message}`);process.exitCode=1;});
package/src/play.js ADDED
@@ -0,0 +1,5 @@
1
+ // The dev version's play link, the same for every upload, so reopening it or a reload after a watched save rejoins
2
+ // the same room, playing as the signed-in developer.
3
+ export function devPlayUrl(game) {
4
+ return `${game.urls.dev}/dev`;
5
+ }
package/src/project.js ADDED
@@ -0,0 +1,206 @@
1
+ // The project as dewdrop.json describes it, its build, and the archive its bundle folder is uploaded as.
2
+ import {readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, openSync, closeSync, fstatSync, readSync, constants, realpathSync, mkdirSync, rmSync} from 'node:fs';
3
+ import {resolve, relative, join, isAbsolute, basename, dirname} from 'node:path';
4
+ import {createHash,randomBytes} from 'node:crypto';
5
+ import {spawn} from 'node:child_process';
6
+ import {packTar} from './tar.js';
7
+ import {RUN} from './command.js';
8
+
9
+ const MAX_BYTES = 1024 * 1024 * 1024;
10
+ const ignored = new Set(['node_modules','dist','target']);
11
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
12
+ const GAME_NAME = /^[a-z][a-z0-9-]{0,47}$/;
13
+ export const DEWDROP = 'https://alpha.dewdrop.world';
14
+ // Dewdrop's address, or the one DEWDROP_PLATFORM names for tests: an https:// origin, or plain http:// to this
15
+ // computer's own loopback address, which a credential never leaves. The slash an address bar adds is the same origin.
16
+ export function platformOrigin(value = process.env.DEWDROP_PLATFORM ?? DEWDROP) {
17
+ const url = URL.parse(value);
18
+ if (!url || (url.origin !== value && `${url.origin}/` !== value) || !(url.protocol === 'https:' || url.protocol === 'http:' && url.hostname === '127.0.0.1')) {
19
+ throw new Error(`DEWDROP_PLATFORM must be an https:// address with no path, like https://example.com, not ${value}.`);
20
+ }
21
+ return url.origin;
22
+ }
23
+ export function projectPath(root, path) {
24
+ if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some(p=>p === '..' || p === '.' || !p)) throw new Error(`Path must be relative to the project: ${path}`);
25
+ const file = resolve(root,path);
26
+ if (!file.startsWith(`${resolve(root)}/`)) throw new Error(`Path leaves the project: ${path}`);
27
+ return file;
28
+ }
29
+ export function readProject(root = process.cwd()) {
30
+ root = realpathSync(root);
31
+ const file = join(root,'dewdrop.json');
32
+ if (!existsSync(file)) throw new Error(`No dewdrop.json here. Run this in your game's folder, or ${RUN} init to start one.`);
33
+ let config;
34
+ try { config = JSON.parse(readFileSync(file,'utf8')); }
35
+ catch (error) { throw new Error(`dewdrop.json isn't valid JSON: ${error.message}`); }
36
+ if (config.version !== 1) throw new Error('dewdrop.json needs "version": 1.');
37
+ if (!GAME_NAME.test(config.game ?? '')) throw new Error("Set game in dewdrop.json to the game's name (for example my-game).");
38
+ projectPath(root,config.bundle);
39
+ if (config.thumbnail !== undefined) projectPath(root,config.thumbnail);
40
+ if (config.build !== undefined && (!Array.isArray(config.build) || !config.build.length || config.build.some(v=>typeof v !== 'string' || !v || v.includes('\0')))) throw new Error('build must be an argument array, such as ["npm","run","build:dew"].');
41
+ return {...config,root};
42
+ }
43
+
44
+ // Opens a file of the project as a regular file under `root` and no symlink, hands `fn` its descriptor, and closes it.
45
+ function opened(file, root, fn) {
46
+ const actual = realpathSync(file);
47
+ if (!actual.startsWith(`${root}/`) || actual !== file) throw new Error(`Symlinks cannot be uploaded: ${file}`);
48
+ const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW);
49
+ try {
50
+ const stat=fstatSync(fd);
51
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error(`Not a regular file under 1 GiB: ${file}`);
52
+ return fn(fd,stat);
53
+ } finally { closeSync(fd); }
54
+ }
55
+ const readRegular=(file,root)=>opened(file,root,fd=>readFileSync(fd));
56
+ // Copies the file's `size` bytes into `target` at `offset`, straight from the descriptor, and answers how many it
57
+ // copied: a file whose size changed since it was walked copies another number, and the archive refuses it.
58
+ function readInto(file, root, size, target, offset) {
59
+ return opened(file,root,(fd,stat)=>{
60
+ if (stat.size!==size) return stat.size;
61
+ let copied=0;
62
+ while (copied<size) {
63
+ const count=readSync(fd,target,offset+copied,size-copied,copied);
64
+ if (count===0) break;
65
+ copied+=count;
66
+ }
67
+ return copied;
68
+ });
69
+ }
70
+ // Every regular file under `start`, by its path under `root`, with its size: what is walked is not read, so a
71
+ // bundle over the budget is refused on its sizes alone and the files are read once, into the archive. Names starting
72
+ // with a dot are always left out; watching also leaves out the bundle folder, node_modules, dist and target, and
73
+ // passes over the symlinks an upload refuses.
74
+ function walk(root, start, files, {watch = false, output, budget = {bytes:0}} = {}) {
75
+ if (watch && (start === output || start.startsWith(`${output}/`))) return;
76
+ if (!existsSync(start)) return;
77
+ const stat=lstatSync(start);
78
+ if (stat.isSymbolicLink()) {
79
+ if (watch) return;
80
+ throw new Error(`Symlinks cannot be uploaded: ${start}`);
81
+ }
82
+ if (stat.isDirectory()) {
83
+ for (const name of readdirSync(start).sort()) {
84
+ if (name.startsWith('.') || watch && ignored.has(name)) continue;
85
+ walk(root,join(start,name),files,{watch,output,budget});
86
+ }
87
+ } else {
88
+ if (files.size >= 4096) throw new Error(watch ? 'More than 4096 files are in the project folder, too many for dev to follow. Move what the game doesn\'t need out of it (node_modules, dist, target, the bundle folder and names starting with a dot don\'t count).' : 'The build\'s output has more than 4096 files, the most Dewdrop takes.');
89
+ if (!watch && (budget.bytes += stat.size) > MAX_BYTES) throw new Error('The build\'s output is over 1 GiB, the most Dewdrop takes.');
90
+ files.set(relative(root,start).split('\\').join('/'),{file:start,size:stat.size});
91
+ }
92
+ }
93
+ // The project's files as one digest of their paths and bytes, which changes whenever any file the walk follows does.
94
+ export function fingerprint(project) {
95
+ const files=new Map();
96
+ walk(project.root,project.root,files,{watch:true,output:projectPath(project.root,project.bundle)});
97
+ const digest=createHash('sha256');
98
+ for (const [path,{file}] of [...files].sort(([a],[b])=>a<b?-1:1)) { const data=readRegular(file,project.root); digest.update(`${path}\0${data.length}\0`).update(data); }
99
+ return digest.digest('hex');
100
+ }
101
+ export function snapshot(project) {
102
+ const directory=projectPath(project.root,project.bundle);
103
+ if (!existsSync(directory)) throw new Error(project.build
104
+ ? `The build wrote no ${project.bundle} folder. Set "bundle" in dewdrop.json to the folder your Dewdrop build (vite build --mode dew) writes.`
105
+ : `There's no ${project.bundle} folder to upload. Build the game first, or set "build" in dewdrop.json so ${RUN} dev builds it.`);
106
+ const root=realpathSync(directory);
107
+ if (root!==directory) throw new Error(`The ${project.bundle} folder can't be a symlink.`);
108
+ const files=new Map();
109
+ walk(root,root,files);
110
+ // The Dewdrop build writes main.js at the top of its output; one deeper down means "bundle" names the folder above it.
111
+ if (!files.has('main.js')) {
112
+ const nested=[...files.keys()].filter(path=>path.endsWith('/main.js')).sort((a,b)=>a.split('/').length-b.split('/').length)[0];
113
+ throw new Error(nested
114
+ ? `There's no main.js at the top of ${project.bundle}, but there is ${project.bundle}/${nested}. Set "bundle": "${project.bundle}/${dirname(nested)}" in dewdrop.json.`
115
+ : `There's no main.js in ${project.bundle}. Build with the Dewdrop plugin (vite build --mode dew), and set "bundle" in dewdrop.json to the folder that build writes.`);
116
+ }
117
+ let bytes=0;
118
+ const entries=[...files].map(([path,{file,size}])=>{
119
+ if (path.length>240 || !path.split('/').every(p=>/^[A-Za-z0-9_][A-Za-z0-9_. -]*$/.test(p)&&!p.endsWith('.')&&!p.endsWith(' '))) throw new Error(`Can't upload ${path}: a name in it must start with a letter, digit or _ and hold only letters, digits, _, ., - and spaces, and the whole path must be under 240 characters.`);
120
+ bytes+=size;
121
+ return {path,size,read:(target,offset)=>readInto(file,root,size,target,offset)};
122
+ });
123
+ // The same files always make the same bytes, which give the upload its id.
124
+ const body=packTar(entries);
125
+ return {body,id:hash(body),bytes,files:files.size};
126
+ }
127
+ export async function build(project, signal) {
128
+ if (!project.build) return;
129
+ signal?.throwIfAborted();
130
+ const [command,...args]=project.build;
131
+ const group=process.platform!=='win32';
132
+ await new Promise((resolve,reject)=>{
133
+ const child=spawn(command,args,{cwd:project.root,stdio:'inherit',shell:false,detached:group});
134
+ function kill(which) {
135
+ try { if(group&&child.pid)process.kill(-child.pid,which);else child.kill(which); }
136
+ catch(error) { if(error.code!=='ESRCH')reject(error); }
137
+ }
138
+ function cancel() {
139
+ kill('SIGTERM');
140
+ // npm may exit before its build grandchildren; keep the process group bounded too.
141
+ setTimeout(()=>kill('SIGKILL'),1000);
142
+ }
143
+ function cleanup() { signal?.removeEventListener('abort',cancel); }
144
+ child.once('error',error=>{cleanup();reject(new Error(`Couldn't run ${command} (${error.code==='ENOENT'?'not found':error.message}). Check "build" in dewdrop.json.`));});
145
+ child.once('exit',(code,sig)=>{
146
+ cleanup();
147
+ if(signal?.aborted)reject(signal.reason);
148
+ else if(code===0)resolve();
149
+ else reject(new Error(`The build (${project.build.join(' ')}) failed with ${sig?`signal ${sig}`:`exit code ${code}`}; the dev version is unchanged.`));
150
+ });
151
+ signal?.addEventListener('abort',cancel,{once:true});
152
+ if(signal?.aborted)cancel();
153
+ });
154
+ }
155
+ // The one project shape: a Vite project with the SDK's plugin, which previews locally with `npm run dev` and
156
+ // builds for Dewdrop with `npm run build:dew`. A directory holding none of the starter's files gets the
157
+ // starter game, depending on the SDK and this CLI from npm; one holding any of them has a game of its own and gets
158
+ // dewdrop.json alone, so nothing of it is touched and no starter is left half written.
159
+ const STARTER_FILES=['package.json','src','index.html','vite.config.js'];
160
+ // The SDK a new game depends on, and this CLI, at its own version.
161
+ const SDK_RANGE='^0.3.1';
162
+ const CLI_RANGE=`^${JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version}`;
163
+ // A game name from the project directory, or a random one when the directory name yields nothing usable.
164
+ function gameNameFrom(root) {
165
+ const slug=basename(resolve(root)).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[^a-z]+/,'').replace(/-+$/,'').slice(0,48).replace(/-+$/,'');
166
+ return GAME_NAME.test(slug)?slug:`game-${randomBytes(4).toString('hex')}`;
167
+ }
168
+ // The build command for a project of its own: its build:dew or build npm script when it has one, named so init can
169
+ // say which it chose, else the default with no script, so init can tell the developer to add one.
170
+ function buildCommandFor(root) {
171
+ try {
172
+ const scripts=JSON.parse(readFileSync(join(root,'package.json'),'utf8')).scripts;
173
+ for(const name of ['build:dew','build'])if(scripts&&typeof scripts[name]==='string')return {build:['npm','run',name],script:name};
174
+ } catch {}
175
+ return {build:['npm','run','build:dew'],script:null};
176
+ }
177
+ export function initProject(root, game) {
178
+ const path=join(root,'dewdrop.json');
179
+ if(existsSync(path))throw new Error('This folder already has a dewdrop.json.');
180
+ const starter=!STARTER_FILES.some(file=>existsSync(join(root,file)));
181
+ game??=gameNameFrom(root);
182
+ if(!GAME_NAME.test(game))throw new Error('A game name is 1–48 lowercase letters, digits or dashes, starting with a letter.');
183
+ const {build,script}=starter?{build:['npm','run','build:dew'],script:'build:dew'}:buildCommandFor(root);
184
+ const config={version:1,game,bundle:'dist',build};
185
+ // Everything init writes, so a failure part-way removes it again and a retry starts from the same empty folder.
186
+ const made=[];
187
+ const write=(file,data)=>{writeFileSync(file,data,{flag:'wx'});made.push(file);};
188
+ const folder=dir=>{if(!existsSync(dir)){mkdirSync(dir,{recursive:true});made.push(dir);}};
189
+ try {
190
+ if(starter) {
191
+ folder(join(root,'src'));
192
+ const template=name=>readFileSync(new URL(`../examples/starter/${name}`,import.meta.url),'utf8');
193
+ write(join(root,'src/main.js'),template('main.js'));
194
+ write(join(root,'index.html'),template('index.html').replace('<title>My game</title>',`<title>${game}</title>`));
195
+ write(join(root,'vite.config.js'),template('vite.config.js'));
196
+ const devDependencies={'dewdrop-sdk':SDK_RANGE,dewctl:CLI_RANGE,vite:'^8'};
197
+ write(join(root,'package.json'),JSON.stringify({name:game,private:true,type:'module',scripts:{dev:'vite','build:dew':'vite build --mode dew'},dependencies:{three:'0.185.1'},devDependencies},null,2)+'\n');
198
+ if(!existsSync(join(root,'.gitignore')))write(join(root,'.gitignore'),'dist/\nnode_modules/\n.env*\n');
199
+ }
200
+ write(path,JSON.stringify(config,null,2)+'\n');
201
+ } catch(error) {
202
+ for(const file of made.reverse())rmSync(file,{recursive:true,force:true});
203
+ throw error;
204
+ }
205
+ return {...config,starter,buildScript:script};
206
+ }
package/src/tar.js ADDED
@@ -0,0 +1,48 @@
1
+ // The archive an upload is sent as: ustar, regular files only, written byte for byte the same for the same files.
2
+ const BLOCK=512;
3
+ const octal=(value,length)=>value.toString(8).padStart(length-1,'0')+'\0';
4
+ const field=(block,offset,length,text)=>block.write(text,offset,length,'latin1');
5
+
6
+ function header(path,size) {
7
+ const block=Buffer.alloc(BLOCK);
8
+ let name=path,prefix='';
9
+ if(Buffer.byteLength(path)>100) {
10
+ const cut=path.lastIndexOf('/',155);
11
+ if(cut<1||Buffer.byteLength(path.slice(cut+1))>100)throw new Error(`This file's path is too long to upload: ${path}`);
12
+ prefix=path.slice(0,cut);
13
+ name=path.slice(cut+1);
14
+ }
15
+ field(block,0,100,name);
16
+ field(block,100,8,octal(0o644,8));
17
+ field(block,108,8,octal(0,8));
18
+ field(block,116,8,octal(0,8));
19
+ field(block,124,12,octal(size,12));
20
+ field(block,136,12,octal(0,12));
21
+ field(block,148,8,' ');
22
+ field(block,156,1,'0');
23
+ field(block,257,6,'ustar\0');
24
+ field(block,263,2,'00');
25
+ field(block,345,155,prefix);
26
+ let sum=0;
27
+ for(const byte of block)sum+=byte;
28
+ field(block,148,8,sum.toString(8).padStart(6,'0')+'\0 ');
29
+ return block;
30
+ }
31
+
32
+ // One archive of `entries` — `{path, size, read}` each, `read(target, offset)` copying the file's `size` bytes into
33
+ // `target` at `offset` and answering how many it copied — in path order, with no owner, time or mode but the fixed
34
+ // ones. The archive is one buffer the size of the whole, each file read straight into its place, so an upload is
35
+ // held in memory once however large it is.
36
+ const padded=size=>size+(size%BLOCK?BLOCK-size%BLOCK:0);
37
+ export function packTar(entries) {
38
+ const sorted=[...entries].sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
39
+ const out=Buffer.alloc(sorted.reduce((sum,{size})=>sum+BLOCK+padded(size),0)+2*BLOCK);
40
+ let offset=0;
41
+ for(const {path,size,read} of sorted) {
42
+ header(path,size).copy(out,offset);
43
+ offset+=BLOCK;
44
+ if(read(out,offset)!==size)throw new Error(`${path} changed while it was being uploaded. Try again.`);
45
+ offset+=padded(size);
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,41 @@
1
+ // The game's thumbnail as dewdrop.json names it: read, checked for what Dewdrop takes, and named by its content, the
2
+ // way the game's thumbnail link ends, so a publish uploads it only when it differs from the one the game shows.
3
+ import {createHash} from 'node:crypto';
4
+ import {existsSync, readFileSync, realpathSync, openSync, fstatSync, closeSync, constants} from 'node:fs';
5
+ import {projectPath} from './project.js';
6
+
7
+ const MAX_BYTES = 1024 * 1024;
8
+ export const TYPES = {png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp'};
9
+ // What the image's bytes say it is: Dewdrop takes PNG, JPEG and WebP, by their bytes, and nothing else.
10
+ function typeOf(bytes) {
11
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
12
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
13
+ if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'webp';
14
+ return null;
15
+ }
16
+ // The image is the project's own file: a symlink, which could reach anything on this computer, is refused as the
17
+ // bundle's files are, and its size decides before its bytes are read, so a path to a huge file is refused, not loaded.
18
+ export function readThumbnail(project) {
19
+ const path = projectPath(project.root, project.thumbnail);
20
+ if (!existsSync(path)) throw new Error(`The thumbnail dewdrop.json names, ${project.thumbnail}, isn't there.`);
21
+ if (realpathSync(path) !== path) throw new Error(`${project.thumbnail} is a symlink; name the image itself, inside the project.`);
22
+ let bytes;
23
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
24
+ try {
25
+ const stat = fstatSync(fd);
26
+ if (!stat.isFile()) throw new Error(`The thumbnail dewdrop.json names, ${project.thumbnail}, isn't a file.`);
27
+ if (stat.size > MAX_BYTES) throw new Error(`${project.thumbnail} is over 1 MB, the most a thumbnail can be.`);
28
+ bytes = readFileSync(fd);
29
+ } finally { closeSync(fd); }
30
+ if (bytes.length > MAX_BYTES) throw new Error(`${project.thumbnail} is over 1 MB, the most a thumbnail can be.`);
31
+ const type = typeOf(bytes);
32
+ if (!type) throw new Error(`${project.thumbnail} isn't a PNG, JPEG or WebP image.`);
33
+ return {bytes, type, file: `${createHash('sha256').update(bytes).digest('hex')}.${type}`};
34
+ }
35
+ // Uploads the project's thumbnail unless the game already shows it; answers whether it uploaded.
36
+ export async function syncThumbnail(project, client, game, signal) {
37
+ const thumbnail = readThumbnail(project);
38
+ if (game.urls.thumbnail?.split('/').at(-1) === thumbnail.file) return false;
39
+ await client.thumbnail(project.game, thumbnail.bytes, TYPES[thumbnail.type], signal);
40
+ return true;
41
+ }
package/src/watch.js ADDED
@@ -0,0 +1,58 @@
1
+ // Poll the project's files across edits, deletions and atomic saves; only one build and activation runs at a time.
2
+ import {PART_BYTES} from './client.js';
3
+ import {fingerprint,build,snapshot} from './project.js';
4
+ import {devPlayUrl} from './play.js';
5
+ import {RUN} from './command.js';
6
+
7
+ // A byte count as a person reads it.
8
+ export function size(bytes) {
9
+ if(bytes<1024)return `${bytes} B`;
10
+ const [value,unit]=bytes<1024**2?[bytes/1024,'KB']:bytes<1024**3?[bytes/1024**2,'MB']:[bytes/1024**3,'GB'];
11
+ return `${value<10?value.toFixed(1):Math.round(value)} ${unit}`;
12
+ }
13
+ // Claims the game before building, so a name someone else has or an ended sign-in is known at once; then builds,
14
+ // uploads and plays the build as the dev version.
15
+ export async function deploy(project,client,{signal,log=console.log}={}) {
16
+ await client.claim(project.game,signal);
17
+ const before=fingerprint(project);
18
+ log('Building…');
19
+ await build(project,signal);
20
+ if(fingerprint(project)!==before)return {changed:true};
21
+ const bundle=snapshot(project);
22
+ const parts=Math.max(1,Math.ceil(bundle.body.length/PART_BYTES));
23
+ log(`Uploading ${bundle.files} file${bundle.files===1?'':'s'} (${size(bundle.bytes)})${parts>1?` in ${parts} parts`:''}…`);
24
+ await client.upload(project.game,bundle,signal);
25
+ const game=await client.activate(project.game,bundle.id,signal);
26
+ log(`Playing ${bundle.id.slice(0,12)} at ${devPlayUrl(game)}`);
27
+ return {changed:false};
28
+ }
29
+
30
+ // Rebuilds on every save. `reread` gives the project as dewdrop.json says it now, so an edit to it applies to the next
31
+ // build; a dewdrop.json that no longer reads waits, like a failed build, for the next save.
32
+ export async function watch(project,client,{signal,log=console.log,deployGame=deploy,readFingerprint=fingerprint,reread=()=>project,pollMs=200,debounceMs=500}={}) {
33
+ let observed=readFingerprint(project),changedAt=Date.now()-debounceMs,dirty=true;
34
+ let running=null;
35
+ log(`Watching ${project.game} for changes. Ctrl-C stops.`);
36
+ while(!signal?.aborted) {
37
+ try {
38
+ const next=readFingerprint(project);
39
+ if(next!==observed){observed=next;changedAt=Date.now();dirty=true;}
40
+ if(dirty&&!running&&Date.now()-changedAt>=debounceMs) {
41
+ dirty=false;
42
+ project=reread();
43
+ running=deployGame(project,client,{signal,log}).then(result=>{
44
+ if(result.changed)dirty=true;
45
+ }).catch(error=>{
46
+ if(signal?.aborted)return;
47
+ log(`dewctl: ${error.message}`);
48
+ if(error.status===401)log(`Paused until you sign in: run ${RUN} login in another terminal, then save a file.`);
49
+ else if(error.transient){log('Trying again in a moment.');dirty=true;changedAt=Date.now()+2000;}
50
+ else log('Fix it and save to try again.');
51
+ }).finally(()=>{running=null;});
52
+ }
53
+ }catch(error){log(`dewctl: ${error.message}`);log('Fix it and save to try again.');}
54
+ await new Promise(resolve=>setTimeout(resolve,pollMs));
55
+ }
56
+ await running;
57
+ log('Stopped watching. The dev version keeps running.');
58
+ }