neme-world 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -2
  3. package/package.json +14 -4
  4. package/src/cli.js +208 -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,76 @@
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 zips `dir` and uploads it as a new version, which goes live at once. Hidden files (anything starting with a
47
+ dot) and `node_modules` are never uploaded. Every version is kept, and you can make any earlier one live again
48
+ from your dashboard.
49
+
50
+ ## Options
51
+
52
+ Installed (`npm i -D neme-world`), the command is simply `neme`.
53
+
54
+ ```
55
+ neme publish [dir] Publish the game described by dir/neme.json (default: current folder)
56
+ neme init [dir] Create a neme.json
57
+
58
+ --token <token> API token (default: $NEME_TOKEN)
59
+ --api <url> Platform address (default: $NEME_API or https://neme.world)
60
+ ```
61
+
62
+ ## In CI
63
+
64
+ ```yaml
65
+ - run: npm run build
66
+ - run: npx neme-world publish
67
+ env:
68
+ NEME_TOKEN: ${{ secrets.NEME_TOKEN }}
69
+ ```
70
+
71
+ ## Talking to the player
72
+
73
+ To report load time, scores and your own events to your dashboard, see the SDK section of the
74
+ [developer guide](https://neme.world/developers).
75
+
76
+ MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,16 @@
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.1.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
+ "dependencies": {
14
+ "fflate": "^0.8.3"
15
+ }
16
+ }
package/src/cli.js ADDED
@@ -0,0 +1,208 @@
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 { realpathSync } from 'node:fs'
12
+ import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
13
+ import path from 'node:path'
14
+ import { parseArgs } from 'node:util'
15
+ import { zipSync } from 'fflate'
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
+ * Collects every publishable file under `root`. Dotfiles and node_modules are
60
+ * left out so secrets and dependencies don't get uploaded by accident.
61
+ * @param {string} root
62
+ * @returns {Promise<Record<string, Uint8Array>>}
63
+ */
64
+ export async function collectFiles(root) {
65
+ /** @type {Record<string, Uint8Array>} */
66
+ const files = {}
67
+ /** @param {string} rel */
68
+ async function walk(rel) {
69
+ for (const entry of await readdir(path.join(root, rel), { withFileTypes: true })) {
70
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
71
+ const child = rel ? `${rel}/${entry.name}` : entry.name
72
+ if (entry.isDirectory()) await walk(child)
73
+ else if (entry.isFile() && child !== 'neme.json') files[child] = await readFile(path.join(root, child))
74
+ }
75
+ }
76
+ await walk('')
77
+ return files
78
+ }
79
+
80
+ /**
81
+ * @param {string} api
82
+ * @param {string} token
83
+ * @param {string} method
84
+ * @param {string} route
85
+ * @param {{ json?: unknown, body?: Uint8Array }} [o]
86
+ */
87
+ async function call(api, token, method, route, o = {}) {
88
+ /** @type {Record<string, string>} */
89
+ const headers = { authorization: `Bearer ${token}` }
90
+ /** @type {BodyInit | undefined} */
91
+ let body
92
+ if (o.json !== undefined) {
93
+ headers['content-type'] = 'application/json'
94
+ body = JSON.stringify(o.json)
95
+ } else if (o.body) {
96
+ headers['content-type'] = 'application/zip'
97
+ body = /** @type {BodyInit} */ (o.body)
98
+ }
99
+ let res
100
+ try {
101
+ res = await fetch(`${api}/api/v1${route}`, { method, headers, body })
102
+ } catch (err) {
103
+ throw new CliError(`Could not reach ${api}: ${/** @type {Error} */ (err).cause ?? /** @type {Error} */ (err).message}`)
104
+ }
105
+ const data = /** @type {any} */ (await res.json().catch(() => ({})))
106
+ if (!res.ok) throw new CliError(`${data.error ?? `Request failed (${res.status})`}`)
107
+ return data
108
+ }
109
+
110
+ /** @param {number} n */
111
+ const size = (n) => (n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
112
+
113
+ /**
114
+ * @param {{ dir: string, api: string, token: string, log?: (line: string) => void }} o
115
+ * @returns {Promise<{ version: number, url: string, warnings: string[] }>}
116
+ */
117
+ export async function publish({ dir, api, token, log = console.log }) {
118
+ const manifest = await readManifest(dir)
119
+ const buildDir = path.resolve(dir, manifest.dir ?? '.')
120
+ const entry = manifest.entry ?? 'index.html'
121
+
122
+ const info = await stat(buildDir).catch(() => null)
123
+ if (!info?.isDirectory()) throw new CliError(`Build folder ${buildDir} does not exist. Did you run your build?`)
124
+ const files = await collectFiles(buildDir)
125
+ if (!files[entry]) throw new CliError(`${buildDir} has no ${entry}. Set "dir" in neme.json to your build output folder.`)
126
+ if (manifest.cover && !files[manifest.cover]) throw new CliError(`Cover "${manifest.cover}" was not found in ${buildDir}.`)
127
+
128
+ const count = Object.keys(files).length
129
+ const zip = zipSync(files, { level: 6 })
130
+ log(`${manifest.title}: ${count} file${count === 1 ? '' : 's'}, ${size(zip.length)} zipped`)
131
+
132
+ const { slug, dir: _dir, entry: _entry, ...details } = manifest
133
+ await call(api, token, 'PUT', `/games/${slug}`, { json: details })
134
+ const out = await call(api, token, 'POST', `/games/${slug}/versions?entry=${encodeURIComponent(entry)}`, { body: zip })
135
+
136
+ for (const w of out.warnings ?? []) log(` warning: ${w}`)
137
+ const saved = out.transferBytes < out.bytes ? ` (${size(out.bytes)} before compression)` : ''
138
+ log(` v${out.version} is live — ${size(out.transferBytes)} to download${saved}`)
139
+ log(` ${out.game.url}`)
140
+ return { version: out.version, url: out.game.url, warnings: out.warnings ?? [] }
141
+ }
142
+
143
+ /** @param {string} dir */
144
+ async function init(dir) {
145
+ const file = path.join(dir, 'neme.json')
146
+ if (await stat(file).catch(() => null)) throw new CliError(`${file} already exists.`)
147
+ const name = path.basename(path.resolve(dir))
148
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'my-game'
149
+ const hasDist = await stat(path.join(dir, 'dist')).catch(() => null)
150
+ const manifest = {
151
+ slug,
152
+ title: name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
153
+ description: '',
154
+ tags: [],
155
+ dir: hasDist ? 'dist' : '.',
156
+ }
157
+ await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`)
158
+ console.log(`Wrote ${file}. Edit it, then run "neme publish".`)
159
+ }
160
+
161
+ const HELP = `neme — publish web games to neme.world
162
+
163
+ Usage
164
+ neme init [dir] Create a neme.json in dir (default: current folder)
165
+ neme publish [dir] Publish the game described by dir/neme.json
166
+
167
+ Options
168
+ --api <url> Platform address (default: $NEME_API or ${DEFAULT_API})
169
+ --token <token> API token (default: $NEME_TOKEN). Create one in your dashboard.
170
+ -h, --help Show this help
171
+ -v, --version Show the version
172
+ `
173
+
174
+ /** @param {string[]} argv */
175
+ export async function main(argv) {
176
+ const { values, positionals } = parseArgs({
177
+ args: argv,
178
+ allowPositionals: true,
179
+ options: {
180
+ api: { type: 'string' }, token: { type: 'string' },
181
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
182
+ },
183
+ })
184
+ const [command, dir = '.'] = positionals
185
+ if (values.version) {
186
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
187
+ return void console.log(pkg.version)
188
+ }
189
+ if (values.help || !command) return void console.log(HELP)
190
+
191
+ if (command === 'init') return init(dir)
192
+ if (command === 'publish') {
193
+ const token = values.token ?? process.env.NEME_TOKEN
194
+ if (!token) throw new CliError('No API token. Set NEME_TOKEN or pass --token. Create one at https://neme.world/dashboard')
195
+ const api = (values.api ?? process.env.NEME_API ?? DEFAULT_API).replace(/\/+$/, '')
196
+ await publish({ dir, api, token })
197
+ return
198
+ }
199
+ throw new CliError(`Unknown command "${command}". Try "neme --help".`)
200
+ }
201
+
202
+ // Run only when executed directly (including through an npm bin symlink), not when imported.
203
+ if (process.argv[1] && import.meta.filename === realpathSync(process.argv[1])) {
204
+ main(process.argv.slice(2)).catch((err) => {
205
+ console.error(err instanceof CliError ? `neme: ${err.message}` : err)
206
+ process.exit(1)
207
+ })
208
+ }