@onceworlds/cli 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 +51 -0
- package/dist/cli.js +351 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @onceworlds/cli
|
|
2
|
+
|
|
3
|
+
Publishes browser games to [Onceworlds](https://onceworlds.com), where every game
|
|
4
|
+
gets multiplayer, saves, player accounts and a shared overlay (sound, chat,
|
|
5
|
+
players) with no server code.
|
|
6
|
+
|
|
7
|
+
## Deploy from GitHub Actions
|
|
8
|
+
|
|
9
|
+
Add `.github/workflows/deploy.yml` to your game's repo:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
name: Deploy to Onceworlds
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
id-token: write # proves which repo is deploying; no API keys needed
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v5
|
|
23
|
+
- uses: actions/setup-node@v5
|
|
24
|
+
with:
|
|
25
|
+
node-version: 22
|
|
26
|
+
- run: npx --yes @onceworlds/cli@latest deploy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pushes to `main` go live at `onceworlds.com/play/<slug>`; other branches get a
|
|
30
|
+
preview URL in the job log. Sign in at https://onceworlds.com with the GitHub
|
|
31
|
+
account that owns the repo (or any member of its organization) before the first
|
|
32
|
+
deploy.
|
|
33
|
+
|
|
34
|
+
## Project file
|
|
35
|
+
|
|
36
|
+
`onceworlds.json` at the root of the repo:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "slug": "star-party", "name": "Star Party", "description": "Grab stars with everyone online.", "dir": "." }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`dir` is the folder to publish (for example `dist` after a build); it must contain
|
|
43
|
+
`index.html`. `productionBranch` (default `main`) picks the branch that goes live.
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game.
|
|
48
|
+
- `onceworlds dev [dir]` deploys to a local Onceworlds platform and redeploys on every save.
|
|
49
|
+
|
|
50
|
+
Games talk to the platform through the injected `window.onceworlds` SDK. The
|
|
51
|
+
starter template's `AGENTS.md` documents it for AI coding tools.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { watch } from "node:fs";
|
|
7
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
8
|
+
import { join, resolve, sep } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
|
|
11
|
+
// ../protocol/src/index.ts
|
|
12
|
+
var OIDC_AUDIENCE = "onceworlds";
|
|
13
|
+
var LIMITS = {
|
|
14
|
+
roomMaxPlayersDefault: 16,
|
|
15
|
+
roomMaxPlayersCap: 50,
|
|
16
|
+
publicServersPerGame: 20,
|
|
17
|
+
messageBytes: 16 * 1024,
|
|
18
|
+
presenceBytes: 1024,
|
|
19
|
+
stateKeyLength: 64,
|
|
20
|
+
stateValueBytes: 16 * 1024,
|
|
21
|
+
stateKeys: 256,
|
|
22
|
+
messagesPerSecond: 60,
|
|
23
|
+
presenceHz: 20,
|
|
24
|
+
chatLength: 200,
|
|
25
|
+
chatIntervalMs: 750,
|
|
26
|
+
chatHistory: 50,
|
|
27
|
+
saveValueBytes: 64 * 1024,
|
|
28
|
+
saveKeysPerGame: 100,
|
|
29
|
+
deployFiles: 2e3,
|
|
30
|
+
deployFileBytes: 25 * 1024 * 1024,
|
|
31
|
+
deployTotalBytes: 100 * 1024 * 1024,
|
|
32
|
+
envVars: 50,
|
|
33
|
+
envValueBytes: 4 * 1024,
|
|
34
|
+
proxyRequestBytes: 1024 * 1024,
|
|
35
|
+
proxyResponseBytes: 10 * 1024 * 1024,
|
|
36
|
+
proxyCallsPerDay: 2e3,
|
|
37
|
+
errorReportsPerPage: 10
|
|
38
|
+
};
|
|
39
|
+
var CONTENT_TYPES = {
|
|
40
|
+
html: "text/html; charset=utf-8",
|
|
41
|
+
htm: "text/html; charset=utf-8",
|
|
42
|
+
js: "text/javascript; charset=utf-8",
|
|
43
|
+
mjs: "text/javascript; charset=utf-8",
|
|
44
|
+
css: "text/css; charset=utf-8",
|
|
45
|
+
json: "application/json; charset=utf-8",
|
|
46
|
+
map: "application/json; charset=utf-8",
|
|
47
|
+
webmanifest: "application/manifest+json",
|
|
48
|
+
txt: "text/plain; charset=utf-8",
|
|
49
|
+
csv: "text/csv; charset=utf-8",
|
|
50
|
+
xml: "application/xml",
|
|
51
|
+
svg: "image/svg+xml",
|
|
52
|
+
png: "image/png",
|
|
53
|
+
jpg: "image/jpeg",
|
|
54
|
+
jpeg: "image/jpeg",
|
|
55
|
+
gif: "image/gif",
|
|
56
|
+
webp: "image/webp",
|
|
57
|
+
avif: "image/avif",
|
|
58
|
+
bmp: "image/bmp",
|
|
59
|
+
ico: "image/x-icon",
|
|
60
|
+
ktx2: "image/ktx2",
|
|
61
|
+
basis: "application/octet-stream",
|
|
62
|
+
hdr: "application/octet-stream",
|
|
63
|
+
mp3: "audio/mpeg",
|
|
64
|
+
ogg: "audio/ogg",
|
|
65
|
+
oga: "audio/ogg",
|
|
66
|
+
opus: "audio/ogg",
|
|
67
|
+
wav: "audio/wav",
|
|
68
|
+
m4a: "audio/mp4",
|
|
69
|
+
flac: "audio/flac",
|
|
70
|
+
mp4: "video/mp4",
|
|
71
|
+
webm: "video/webm",
|
|
72
|
+
glb: "model/gltf-binary",
|
|
73
|
+
gltf: "model/gltf+json",
|
|
74
|
+
bin: "application/octet-stream",
|
|
75
|
+
obj: "text/plain; charset=utf-8",
|
|
76
|
+
mtl: "text/plain; charset=utf-8",
|
|
77
|
+
fbx: "application/octet-stream",
|
|
78
|
+
wasm: "application/wasm",
|
|
79
|
+
pck: "application/octet-stream",
|
|
80
|
+
data: "application/octet-stream",
|
|
81
|
+
woff: "font/woff",
|
|
82
|
+
woff2: "font/woff2",
|
|
83
|
+
ttf: "font/ttf",
|
|
84
|
+
otf: "font/otf",
|
|
85
|
+
fnt: "text/plain; charset=utf-8",
|
|
86
|
+
atlas: "text/plain; charset=utf-8",
|
|
87
|
+
tmx: "application/xml",
|
|
88
|
+
ldtk: "application/json"
|
|
89
|
+
};
|
|
90
|
+
function extensionOf(path) {
|
|
91
|
+
const dot = path.lastIndexOf(".");
|
|
92
|
+
return dot > path.lastIndexOf("/") ? path.slice(dot + 1).toLowerCase() : "";
|
|
93
|
+
}
|
|
94
|
+
function contentTypeFor(path) {
|
|
95
|
+
return CONTENT_TYPES[extensionOf(path)];
|
|
96
|
+
}
|
|
97
|
+
function isValidDeployPath(path) {
|
|
98
|
+
if (path.length === 0 || path.length > 512) return false;
|
|
99
|
+
if (path.startsWith("/") || path.includes("\\")) return false;
|
|
100
|
+
if (/[\u0000-\u001f\u007f]/.test(path)) return false;
|
|
101
|
+
for (const segment of path.split("/")) {
|
|
102
|
+
if (segment === "" || segment === "." || segment === "..") return false;
|
|
103
|
+
}
|
|
104
|
+
return contentTypeFor(path) !== void 0;
|
|
105
|
+
}
|
|
106
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
107
|
+
var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "static", "assets", "docs", "status"]);
|
|
108
|
+
function isValidSlug(slug) {
|
|
109
|
+
return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/index.ts
|
|
113
|
+
var PRODUCTION_API = "https://onceworlds.com";
|
|
114
|
+
var LOCAL_API = "http://localhost:8787";
|
|
115
|
+
var USAGE = `Usage:
|
|
116
|
+
onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
|
|
117
|
+
onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
|
|
118
|
+
|
|
119
|
+
Options:
|
|
120
|
+
--api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
|
|
121
|
+
--token <t> Deploy token (default: $ONCEWORLDS_TOKEN, GitHub Actions OIDC, or "dev" on localhost)
|
|
122
|
+
|
|
123
|
+
The project directory needs an onceworlds.json:
|
|
124
|
+
{ "slug": "star-party", "name": "Star Party", "dir": "." }
|
|
125
|
+
`;
|
|
126
|
+
var CliError = class extends Error {
|
|
127
|
+
};
|
|
128
|
+
var QUIET_SKIPS = /* @__PURE__ */ new Set(["node_modules", "onceworlds.json", "package.json", "package-lock.json", "tsconfig.json", "jsconfig.json", "LICENSE"]);
|
|
129
|
+
var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock", "log", "yml", "yaml", "sh"]);
|
|
130
|
+
async function main() {
|
|
131
|
+
const { values, positionals } = parseArgs({
|
|
132
|
+
allowPositionals: true,
|
|
133
|
+
options: { api: { type: "string" }, token: { type: "string" }, help: { type: "boolean", short: "h" } }
|
|
134
|
+
});
|
|
135
|
+
const [command, projectArg] = positionals;
|
|
136
|
+
if (values.help || !command || command === "help") {
|
|
137
|
+
console.log(USAGE);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const projectDir = resolve(projectArg ?? ".");
|
|
141
|
+
const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
|
|
142
|
+
if (command === "deploy") {
|
|
143
|
+
await deploy(projectDir, api, values.token);
|
|
144
|
+
} else if (command === "dev") {
|
|
145
|
+
await dev(projectDir, api, values.token);
|
|
146
|
+
} else {
|
|
147
|
+
throw new CliError(`Unknown command "${command}".
|
|
148
|
+
|
|
149
|
+
${USAGE}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
153
|
+
const config = await loadConfig(projectDir);
|
|
154
|
+
const root = resolve(projectDir, config.dir ?? ".");
|
|
155
|
+
const files = await collectFiles(root, quiet);
|
|
156
|
+
if (!files.some((f) => f.path === "index.html")) {
|
|
157
|
+
throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
|
|
158
|
+
}
|
|
159
|
+
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
160
|
+
if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
|
|
161
|
+
if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api}`);
|
|
162
|
+
const token = await getToken(api, tokenArg);
|
|
163
|
+
const start = await send("POST", `${api}/api/deploy`, token, {
|
|
164
|
+
config: {
|
|
165
|
+
slug: config.slug,
|
|
166
|
+
name: config.name,
|
|
167
|
+
description: config.description ?? "",
|
|
168
|
+
productionBranch: config.productionBranch ?? "main"
|
|
169
|
+
},
|
|
170
|
+
files: files.map(({ path, hash, size }) => ({ path, hash, size })),
|
|
171
|
+
git: gitInfo(projectDir)
|
|
172
|
+
});
|
|
173
|
+
const byHash = new Map(files.map((f) => [f.hash, f]));
|
|
174
|
+
const uploads = start.missing.map((hash) => byHash.get(hash));
|
|
175
|
+
if (uploads.length > 0 && !quiet) {
|
|
176
|
+
console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
|
|
177
|
+
}
|
|
178
|
+
await runPool(uploads, 6, async (file) => {
|
|
179
|
+
const body = await readFile(file.abs);
|
|
180
|
+
await send("PUT", `${api}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
|
|
181
|
+
});
|
|
182
|
+
const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
|
|
183
|
+
if (!quiet) {
|
|
184
|
+
if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
|
|
185
|
+
else console.log(`\u2713 Preview (this branch isn't ${config.productionBranch ?? "main"}): ${result.previewUrl}`);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
async function dev(projectDir, api, tokenArg) {
|
|
190
|
+
const config = await loadConfig(projectDir);
|
|
191
|
+
const root = resolve(projectDir, config.dir ?? ".");
|
|
192
|
+
const first = await deploy(projectDir, api, tokenArg);
|
|
193
|
+
console.log(`
|
|
194
|
+
Play (reloads on every change): ${first.playUrl}?watch
|
|
195
|
+
Watching ${root}. Press Ctrl+C to stop.
|
|
196
|
+
`);
|
|
197
|
+
let lastSignature = signature(await collectFiles(root, true));
|
|
198
|
+
let timer;
|
|
199
|
+
let running = false;
|
|
200
|
+
let again = false;
|
|
201
|
+
const run = async () => {
|
|
202
|
+
if (running) {
|
|
203
|
+
again = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
running = true;
|
|
207
|
+
try {
|
|
208
|
+
const current = signature(await collectFiles(root, true));
|
|
209
|
+
if (current !== lastSignature) {
|
|
210
|
+
await deploy(projectDir, api, tokenArg, true);
|
|
211
|
+
lastSignature = current;
|
|
212
|
+
console.log(` \u21BB ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} redeployed`);
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(` \u2717 ${err instanceof Error ? err.message : err}`);
|
|
216
|
+
} finally {
|
|
217
|
+
running = false;
|
|
218
|
+
if (again) {
|
|
219
|
+
again = false;
|
|
220
|
+
trigger();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const trigger = () => {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = setTimeout(run, 250);
|
|
227
|
+
};
|
|
228
|
+
watch(root, { recursive: true }, (_event, filename) => {
|
|
229
|
+
if (filename && filename.split(sep).some((part) => part.startsWith(".") || part === "node_modules")) return;
|
|
230
|
+
trigger();
|
|
231
|
+
});
|
|
232
|
+
await new Promise(() => {
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async function loadConfig(projectDir) {
|
|
236
|
+
const file = join(projectDir, "onceworlds.json");
|
|
237
|
+
let config;
|
|
238
|
+
try {
|
|
239
|
+
config = JSON.parse(await readFile(file, "utf8"));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const missing = err.code === "ENOENT";
|
|
242
|
+
throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
|
|
243
|
+
|
|
244
|
+
${USAGE}` : `Could not parse ${file}: ${err.message}`);
|
|
245
|
+
}
|
|
246
|
+
if (!isValidSlug(config.slug ?? "")) {
|
|
247
|
+
throw new CliError(`"slug" in onceworlds.json must be 1-40 lowercase letters, digits or single dashes (got ${JSON.stringify(config.slug)}).`);
|
|
248
|
+
}
|
|
249
|
+
if (typeof config.name !== "string" || !config.name.trim()) throw new CliError('"name" is required in onceworlds.json.');
|
|
250
|
+
return config;
|
|
251
|
+
}
|
|
252
|
+
async function collectFiles(root, quiet) {
|
|
253
|
+
const files = [];
|
|
254
|
+
const skipped = [];
|
|
255
|
+
async function walk(dir, prefix) {
|
|
256
|
+
let entries;
|
|
257
|
+
try {
|
|
258
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
259
|
+
} catch {
|
|
260
|
+
throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
263
|
+
if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
|
|
264
|
+
const abs = join(dir, entry.name);
|
|
265
|
+
const path = prefix + entry.name;
|
|
266
|
+
if (entry.isDirectory()) {
|
|
267
|
+
await walk(abs, `${path}/`);
|
|
268
|
+
} else if (entry.isFile()) {
|
|
269
|
+
if (!isValidDeployPath(path)) {
|
|
270
|
+
if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const data = await readFile(abs);
|
|
274
|
+
if (data.byteLength > LIMITS.deployFileBytes) {
|
|
275
|
+
throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
|
|
276
|
+
}
|
|
277
|
+
files.push({ path, abs, hash: createHash("sha256").update(data).digest("hex"), size: data.byteLength });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await walk(root, "");
|
|
282
|
+
if (skipped.length > 0 && !quiet) console.warn(` ! skipped unsupported files: ${skipped.join(", ")}`);
|
|
283
|
+
if (files.length > LIMITS.deployFiles) throw new CliError(`Too many files (${files.length}); the limit is ${LIMITS.deployFiles}.`);
|
|
284
|
+
return files;
|
|
285
|
+
}
|
|
286
|
+
function signature(files) {
|
|
287
|
+
return files.map((f) => `${f.path}:${f.hash}`).join("\n");
|
|
288
|
+
}
|
|
289
|
+
function gitInfo(cwd) {
|
|
290
|
+
const run = (args) => {
|
|
291
|
+
try {
|
|
292
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
|
|
293
|
+
} catch {
|
|
294
|
+
return void 0;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
return { sha: run(["rev-parse", "HEAD"]), ref: run(["symbolic-ref", "-q", "HEAD"]) };
|
|
298
|
+
}
|
|
299
|
+
async function getToken(api, tokenArg) {
|
|
300
|
+
if (tokenArg) return tokenArg;
|
|
301
|
+
if (process.env.ONCEWORLDS_TOKEN) return process.env.ONCEWORLDS_TOKEN;
|
|
302
|
+
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
303
|
+
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
304
|
+
if (requestUrl && requestToken) {
|
|
305
|
+
const url = new URL(requestUrl);
|
|
306
|
+
url.searchParams.set("audience", OIDC_AUDIENCE);
|
|
307
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } });
|
|
308
|
+
if (!res.ok) throw new CliError(`Could not get a GitHub OIDC token (${res.status}).`);
|
|
309
|
+
return (await res.json()).value;
|
|
310
|
+
}
|
|
311
|
+
const host = new URL(api).hostname;
|
|
312
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost")) return "dev";
|
|
313
|
+
throw new CliError(
|
|
314
|
+
"No deploy credentials. In GitHub Actions, add `permissions: id-token: write` to the workflow. Elsewhere, set ONCEWORLDS_TOKEN."
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
async function send(method, url, token, body) {
|
|
318
|
+
const binary = body instanceof Uint8Array;
|
|
319
|
+
let res;
|
|
320
|
+
try {
|
|
321
|
+
res = await fetch(url, {
|
|
322
|
+
method,
|
|
323
|
+
headers: {
|
|
324
|
+
Authorization: `Bearer ${token}`,
|
|
325
|
+
"Content-Type": binary ? "application/octet-stream" : "application/json"
|
|
326
|
+
},
|
|
327
|
+
body: binary ? body : JSON.stringify(body)
|
|
328
|
+
});
|
|
329
|
+
} catch {
|
|
330
|
+
throw new CliError(`Could not reach ${new URL(url).origin}. Is the platform running? (npm run dev)`);
|
|
331
|
+
}
|
|
332
|
+
const data = await res.json().catch(() => ({}));
|
|
333
|
+
if (!res.ok) throw new CliError(data.message ?? `${method} ${new URL(url).pathname} failed (${res.status})`);
|
|
334
|
+
return data;
|
|
335
|
+
}
|
|
336
|
+
async function runPool(items, size, task) {
|
|
337
|
+
let next = 0;
|
|
338
|
+
const worker = async () => {
|
|
339
|
+
while (next < items.length) await task(items[next++]);
|
|
340
|
+
};
|
|
341
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
342
|
+
}
|
|
343
|
+
function formatBytes(bytes) {
|
|
344
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
345
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
346
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
347
|
+
}
|
|
348
|
+
main().catch((err) => {
|
|
349
|
+
console.error(err instanceof CliError ? `\u2717 ${err.message}` : err);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onceworlds/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy games to Onceworlds.",
|
|
5
|
+
"homepage": "https://onceworlds.com",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"onceworlds": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"typecheck": "tsc --noEmit -p ."
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@onceworlds/protocol": "0.1.0"
|
|
23
|
+
}
|
|
24
|
+
}
|