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.
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/package.json +14 -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
|
-
#
|
|
1
|
+
# neme-world
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|