neme-world 0.0.0-stage → 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/package.json +11 -4
  4. package/src/cli.js +259 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NEME LIMITED
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 CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # neme-world
2
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.
3
+ Publish a web game to [neme.world](https://neme.world) from the command line.
4
+
5
+ If your game runs in a browser tab from static files, it runs on neme.world: any engine, any framework,
6
+ hand-written or AI-assisted. You keep your own site; neme.world is one more place people can find and play it.
7
+
8
+ ```sh
9
+ npx neme-world init # writes neme.json
10
+ npx neme-world publish # uploads your build and puts it live
11
+ ```
12
+
13
+ ## Before you publish
14
+
15
+ 1. Create an account at [neme.world](https://neme.world/signup) and make an API token in your dashboard.
16
+ 2. Put the token in your environment: `export NEME_TOKEN=neme_…`
17
+ 3. Build your game the way you normally do.
18
+
19
+ ## neme.json
20
+
21
+ ```json
22
+ {
23
+ "slug": "my-game",
24
+ "title": "My Game",
25
+ "description": "One or two sentences players will see.",
26
+ "tags": ["arcade"],
27
+ "dir": "dist",
28
+ "cover": "cover.png"
29
+ }
30
+ ```
31
+
32
+ | Field | Meaning |
33
+ | --- | --- |
34
+ | `slug` | The game's address: `neme.world/g/<slug>`. Lowercase letters, numbers, hyphens. |
35
+ | `title` | Shown to players. |
36
+ | `description` | Shown on the game's page and in link previews. |
37
+ | `tags` | Up to 8, used for browsing. |
38
+ | `dir` | Folder holding your built `index.html`, relative to `neme.json`. Default `.` |
39
+ | `entry` | The HTML file to open, if it isn't `index.html`. |
40
+ | `cover` | An image inside `dir`. A 16:9 PNG makes the best link previews. |
41
+ | `aspect` | Shape of the game on its page: `16:9` (default), `4:3`, `1:1`, `3:4` or `9:16`. |
42
+ | `license`, `sourceUrl`, `credit` | Optional, for open-source games and attribution. |
43
+
44
+ ## What publish does
45
+
46
+ It uploads `dir` file by file as a new version, which goes live at once. Only files that changed since your last
47
+ publish are sent, so updating a large game is quick. Hidden files (anything starting with a dot) and
48
+ `node_modules` are never uploaded. Every version is kept, and you can make any earlier one live again from your
49
+ dashboard.
50
+
51
+ A single file can be up to 95 MB, and a game up to 1 GB. One publish per game runs at a time: starting a second
52
+ replaces the first.
53
+
54
+ ## Options
55
+
56
+ Installed (`npm i -D neme-world`), the command is simply `neme`.
57
+
58
+ ```
59
+ neme publish [dir] Publish the game described by dir/neme.json (default: current folder)
60
+ neme init [dir] Create a neme.json
61
+
62
+ --token <token> API token (default: $NEME_TOKEN)
63
+ --api <url> Platform address (default: $NEME_API or https://neme.world)
64
+ ```
65
+
66
+ ## In CI
67
+
68
+ ```yaml
69
+ - run: npm run build
70
+ - run: npx neme-world publish
71
+ env:
72
+ NEME_TOKEN: ${{ secrets.NEME_TOKEN }}
73
+ ```
74
+
75
+ ## Talking to the player
76
+
77
+ To report load time, scores and your own events to your dashboard, see the SDK section of the
78
+ [developer guide](https://neme.world/developers).
79
+
80
+ MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "neme-world",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Publish web games to neme.world from the command line",
5
+ "keywords": ["neme", "games", "web games", "html5 games", "publish", "deploy", "cli"],
6
+ "homepage": "https://neme.world/developers",
7
+ "license": "MIT",
8
+ "author": "NEME LIMITED",
9
+ "type": "module",
10
+ "bin": { "neme": "src/cli.js" },
11
+ "files": ["src/cli.js"],
12
+ "engines": { "node": ">=20" }
13
+ }
package/src/cli.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * neme — publish web games to neme.world.
5
+ *
6
+ * neme init [dir] write a starter neme.json
7
+ * neme publish [dir] zip the build and publish it as a new version
8
+ *
9
+ * Plain JavaScript on purpose: it runs from a checkout or from npm with no build.
10
+ */
11
+ import { createHash } from 'node:crypto'
12
+ import { createReadStream, openAsBlob, realpathSync } from 'node:fs'
13
+ import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import { parseArgs } from 'node:util'
16
+
17
+ const DEFAULT_API = 'https://neme.world'
18
+ const SKIP_DIRS = new Set(['node_modules'])
19
+
20
+ /**
21
+ * @typedef {object} Manifest
22
+ * @property {string} slug
23
+ * @property {string} title
24
+ * @property {string} [description]
25
+ * @property {string[]} [tags]
26
+ * @property {string} [dir] Folder holding the built index.html, relative to neme.json. Default ".".
27
+ * @property {string} [entry] Default "index.html".
28
+ * @property {string} [cover] Image inside `dir`.
29
+ * @property {string} [aspect] "16:9" | "4:3" | "1:1" | "3:4" | "9:16"
30
+ * @property {string} [license]
31
+ * @property {string} [sourceUrl]
32
+ * @property {string} [credit]
33
+ */
34
+
35
+ export class CliError extends Error {}
36
+
37
+ /** @param {string} dir @returns {Promise<Manifest>} */
38
+ export async function readManifest(dir) {
39
+ const file = path.join(dir, 'neme.json')
40
+ let text
41
+ try {
42
+ text = await readFile(file, 'utf8')
43
+ } catch {
44
+ throw new CliError(`No neme.json in ${dir}. Run "neme init" to create one.`)
45
+ }
46
+ let manifest
47
+ try {
48
+ manifest = JSON.parse(text)
49
+ } catch (err) {
50
+ throw new CliError(`${file} is not valid JSON: ${/** @type {Error} */ (err).message}`)
51
+ }
52
+ if (typeof manifest?.slug !== 'string' || typeof manifest?.title !== 'string') {
53
+ throw new CliError(`${file} needs at least "slug" and "title".`)
54
+ }
55
+ return manifest
56
+ }
57
+
58
+ /**
59
+ * @typedef {object} LocalFile
60
+ * @property {string} path Path inside the build, with forward slashes.
61
+ * @property {string} file Where it is on disk.
62
+ * @property {number} size
63
+ * @property {string} sha256
64
+ */
65
+
66
+ /** @param {string} file @returns {Promise<string>} */
67
+ function sha256Of(file) {
68
+ return new Promise((resolve, reject) => {
69
+ const hash = createHash('sha256')
70
+ createReadStream(file).on('data', (chunk) => hash.update(chunk)).on('error', reject).on('end', () => resolve(hash.digest('hex')))
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Lists every publishable file under `root`, with its size and hash. Dotfiles
76
+ * and node_modules are left out so secrets and dependencies don't get uploaded
77
+ * by accident. Files are hashed from disk, never held in memory whole.
78
+ * @param {string} root
79
+ * @returns {Promise<LocalFile[]>}
80
+ */
81
+ export async function collectFiles(root) {
82
+ /** @type {LocalFile[]} */
83
+ const files = []
84
+ /** @param {string} rel */
85
+ async function walk(rel) {
86
+ for (const entry of await readdir(path.join(root, rel), { withFileTypes: true })) {
87
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
88
+ const child = rel ? `${rel}/${entry.name}` : entry.name
89
+ const file = path.join(root, child)
90
+ if (entry.isDirectory()) await walk(child)
91
+ else if (entry.isFile() && child !== 'neme.json') files.push({ path: child, file, size: (await stat(file)).size, sha256: await sha256Of(file) })
92
+ }
93
+ }
94
+ await walk('')
95
+ return files.sort((a, b) => (a.path < b.path ? -1 : 1))
96
+ }
97
+
98
+ /**
99
+ * @param {string} api
100
+ * @param {string} token
101
+ * @param {string} method
102
+ * @param {string} route
103
+ * @param {{ json?: unknown, body?: Blob }} [o]
104
+ */
105
+ async function call(api, token, method, route, o = {}) {
106
+ /** @type {Record<string, string>} */
107
+ const headers = { authorization: `Bearer ${token}` }
108
+ /** @type {BodyInit | undefined} */
109
+ let body
110
+ if (o.json !== undefined) {
111
+ headers['content-type'] = 'application/json'
112
+ body = JSON.stringify(o.json)
113
+ } else if (o.body) {
114
+ headers['content-type'] = 'application/octet-stream'
115
+ body = o.body
116
+ }
117
+ let res
118
+ try {
119
+ res = await fetch(`${api}/api/v1${route}`, { method, headers, body })
120
+ } catch (err) {
121
+ throw new CliError(`Could not reach ${api}: ${/** @type {Error} */ (err).cause ?? /** @type {Error} */ (err).message}`)
122
+ }
123
+ const data = /** @type {any} */ (res.status === 204 ? {} : await res.json().catch(() => ({})))
124
+ if (!res.ok) throw new CliError(`${data.error ?? `Request failed (${res.status})`}`)
125
+ return data
126
+ }
127
+
128
+ /** @param {number} n */
129
+ const size = (n) => (n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
130
+
131
+ /** How many files are sent at once. */
132
+ const PARALLEL = 6
133
+
134
+ /**
135
+ * Publishes file by file: declare everything, send only what the platform does
136
+ * not already have, then turn the upload into a version.
137
+ * @param {{ dir: string, api: string, token: string, log?: (line: string) => void }} o
138
+ * @returns {Promise<{ version: number, url: string, warnings: string[], uploaded: number }>}
139
+ */
140
+ export async function publish({ dir, api, token, log = console.log }) {
141
+ const manifest = await readManifest(dir)
142
+ const buildDir = path.resolve(dir, manifest.dir ?? '.')
143
+ const entry = manifest.entry ?? 'index.html'
144
+
145
+ const info = await stat(buildDir).catch(() => null)
146
+ if (!info?.isDirectory()) throw new CliError(`Build folder ${buildDir} does not exist. Did you run your build?`)
147
+ const files = await collectFiles(buildDir)
148
+ if (!files.some((f) => f.path === entry)) throw new CliError(`${buildDir} has no ${entry}. Set "dir" in neme.json to your build output folder.`)
149
+ if (manifest.cover && !files.some((f) => f.path === manifest.cover)) throw new CliError(`Cover "${manifest.cover}" was not found in ${buildDir}.`)
150
+
151
+ const total = files.reduce((n, f) => n + f.size, 0)
152
+ log(`${manifest.title}: ${files.length} file${files.length === 1 ? '' : 's'}, ${size(total)}`)
153
+
154
+ const { slug, dir: _dir, entry: _entry, ...details } = manifest
155
+ await call(api, token, 'PUT', `/games/${slug}`, { json: details })
156
+ const begun = await call(api, token, 'POST', `/games/${slug}/uploads`, {
157
+ json: { entry, files: files.map(({ path, size, sha256 }) => ({ path, size, sha256 })) },
158
+ })
159
+
160
+ /** @type {{ sha256: string, size: number }[]} */
161
+ const missing = begun.missing
162
+ const byHash = new Map(files.map((f) => [f.sha256, f]))
163
+ const sending = missing.reduce((n, m) => n + m.size, 0)
164
+ if (missing.length) log(` uploading ${missing.length} file${missing.length === 1 ? '' : 's'} (${size(sending)}); ${files.length - missing.length} already there`)
165
+ else log(' nothing new to upload')
166
+
167
+ const queue = [...missing]
168
+ const send = async () => {
169
+ for (let next = queue.pop(); next; next = queue.pop()) {
170
+ const local = /** @type {LocalFile} */ (byHash.get(next.sha256))
171
+ const route = `/games/${slug}/uploads/${begun.upload}/blobs/${next.sha256}`
172
+ try {
173
+ await call(api, token, 'PUT', route, { body: await openAsBlob(local.file) })
174
+ } catch (err) {
175
+ // One retry covers a dropped connection; anything else is reported with the file's name.
176
+ try {
177
+ await call(api, token, 'PUT', route, { body: await openAsBlob(local.file) })
178
+ } catch (again) {
179
+ throw new CliError(`Could not upload ${local.path}: ${/** @type {Error} */ (again).message}`)
180
+ }
181
+ }
182
+ }
183
+ }
184
+ await Promise.all(Array.from({ length: PARALLEL }, send))
185
+
186
+ const out = await call(api, token, 'POST', `/games/${slug}/uploads/${begun.upload}/complete`, { json: {} })
187
+ for (const w of [...(begun.warnings ?? []), ...(out.warnings ?? [])]) log(` warning: ${w}`)
188
+ const saved = out.transferBytes < out.bytes ? ` (${size(out.bytes)} before compression)` : ''
189
+ log(` v${out.version} is live — ${size(out.transferBytes)} to download${saved}`)
190
+ log(` ${out.game.url}`)
191
+ return { version: out.version, url: out.game.url, warnings: out.warnings ?? [], uploaded: missing.length }
192
+ }
193
+
194
+ /** @param {string} dir */
195
+ async function init(dir) {
196
+ const file = path.join(dir, 'neme.json')
197
+ if (await stat(file).catch(() => null)) throw new CliError(`${file} already exists.`)
198
+ const name = path.basename(path.resolve(dir))
199
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'my-game'
200
+ const hasDist = await stat(path.join(dir, 'dist')).catch(() => null)
201
+ const manifest = {
202
+ slug,
203
+ title: name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
204
+ description: '',
205
+ tags: [],
206
+ dir: hasDist ? 'dist' : '.',
207
+ }
208
+ await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`)
209
+ console.log(`Wrote ${file}. Edit it, then run "neme publish".`)
210
+ }
211
+
212
+ const HELP = `neme — publish web games to neme.world
213
+
214
+ Usage
215
+ neme init [dir] Create a neme.json in dir (default: current folder)
216
+ neme publish [dir] Publish the game described by dir/neme.json
217
+
218
+ Options
219
+ --api <url> Platform address (default: $NEME_API or ${DEFAULT_API})
220
+ --token <token> API token (default: $NEME_TOKEN). Create one in your dashboard.
221
+ -h, --help Show this help
222
+ -v, --version Show the version
223
+ `
224
+
225
+ /** @param {string[]} argv */
226
+ export async function main(argv) {
227
+ const { values, positionals } = parseArgs({
228
+ args: argv,
229
+ allowPositionals: true,
230
+ options: {
231
+ api: { type: 'string' }, token: { type: 'string' },
232
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
233
+ },
234
+ })
235
+ const [command, dir = '.'] = positionals
236
+ if (values.version) {
237
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
238
+ return void console.log(pkg.version)
239
+ }
240
+ if (values.help || !command) return void console.log(HELP)
241
+
242
+ if (command === 'init') return init(dir)
243
+ if (command === 'publish') {
244
+ const token = values.token ?? process.env.NEME_TOKEN
245
+ if (!token) throw new CliError('No API token. Set NEME_TOKEN or pass --token. Create one at https://neme.world/dashboard')
246
+ const api = (values.api ?? process.env.NEME_API ?? DEFAULT_API).replace(/\/+$/, '')
247
+ await publish({ dir, api, token })
248
+ return
249
+ }
250
+ throw new CliError(`Unknown command "${command}". Try "neme --help".`)
251
+ }
252
+
253
+ // Run only when executed directly (including through an npm bin symlink), not when imported.
254
+ if (process.argv[1] && import.meta.filename === realpathSync(process.argv[1])) {
255
+ main(process.argv.slice(2)).catch((err) => {
256
+ console.error(err instanceof CliError ? `neme: ${err.message}` : err)
257
+ process.exit(1)
258
+ })
259
+ }