gamit 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gamit
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,132 @@
1
+ # gamit CLI
2
+
3
+ Publish vibe-coded web games to [gamit.ai](https://gamit.ai) in one command.
4
+ The CLI is a thin client over the public REST API (`/api/v1`) — everything it
5
+ does, an agent with the same API key can do with plain `fetch`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx gamit <command> # no install, always the latest published version
11
+ npm i -g gamit # global install
12
+ ```
13
+
14
+ Requires Node.js >= 18.17. Every release after v0.1.0 is published from CI with
15
+ [npm provenance](https://docs.npmjs.com/generating-provenance-statements) —
16
+ npmjs.com shows a `✓ Built and signed` badge linking the package to the exact
17
+ source commit and workflow run that built it.
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ # 1. authenticate (key from gamit.ai dashboard → API keys)
23
+ gamit login
24
+
25
+ # 2. scaffold a manifest in your game directory
26
+ cd my-game
27
+ gamit init
28
+
29
+ # 3. ship it
30
+ gamit publish
31
+ # ✔ Published → https://gamit.ai/p/my-space/my-game
32
+ # Play: https://gamit.app/play/<projectId>/<buildId>/index.html
33
+ ```
34
+
35
+ Re-running `gamit publish` is an idempotent republish: the first publish
36
+ writes `projectId` back into `gamit.json`, so subsequent runs upload a new
37
+ build to the same project and republish it.
38
+
39
+ ## Sign out
40
+
41
+ ```bash
42
+ gamit logout # removes the stored API key from ~/.gamit/config.json
43
+ ```
44
+
45
+ The API key is stored at `~/.gamit/config.json` (file mode `0600`). `gamit login`
46
+ prompts for the key by default; the `--key <key>` flag is available for
47
+ non-interactive/CI use but lands in your shell history, so prefer the prompt.
48
+
49
+ ## Commands
50
+
51
+ ### `gamit login [--key <key>] [--api-base <url>]`
52
+
53
+ Validates the key against `GET /api/v1/me` and stores it in
54
+ `~/.gamit/config.json` (mode `0600`). Without `--key` it prompts on stdin.
55
+
56
+ ### `gamit init [dir] [--force]`
57
+
58
+ Interactive prompts (title, slug, spaceSlug, kind, entry, engine) that write
59
+ `gamit.json` into `dir` (default `.`). Refuses to overwrite an existing
60
+ manifest unless `--force` is given.
61
+
62
+ ### `gamit publish [dir] [--api-base <url>]`
63
+
64
+ End-to-end publish of `dir` (default `.`):
65
+
66
+ 1. Reads and validates `gamit.json`.
67
+ 2. Packs the directory into a zip (excludes `node_modules/`, `.git/`,
68
+ dot-directories like `.next/`, all dotfiles including `.env*`,
69
+ `gamit.json` itself, and `*.zip`; 4MB limit, entry file must be present).
70
+ 3. Resolves the project: reuses `projectId` if present; otherwise ensures the
71
+ space exists (creates it if you don't own it yet) and creates — or reuses —
72
+ the project by slug.
73
+ 4. Uploads the zip as a new build (`POST /api/v1/projects/{id}/builds`,
74
+ multipart `file`; the server detects the entry HTML).
75
+ 5. Publishes the project (`PATCH {"status":"published"}`).
76
+ 6. Writes `projectId` back into `gamit.json` and prints the page + play URLs.
77
+
78
+ ## gamit.json
79
+
80
+ The manifest is the unit of config; it also becomes the project's
81
+ `ai_manifest` on the platform.
82
+
83
+ ```json
84
+ {
85
+ "title": "Click The Dot",
86
+ "slug": "click-the-dot",
87
+ "spaceSlug": "my-arcade",
88
+ "kind": "game",
89
+ "entry": "index.html",
90
+ "engine": "vanilla-js",
91
+ "summary": "A tiny reflex game.",
92
+ "tags": ["arcade", "casual"]
93
+ }
94
+ ```
95
+
96
+ | Field | Required | Type | Notes |
97
+ | --- | --- | --- | --- |
98
+ | `title` | yes | string | 1–200 chars |
99
+ | `slug` | yes | string | `[a-z0-9-]`, 1–60 chars; unique within the space |
100
+ | `spaceSlug` | yes | string | `[a-z0-9-]`, 3–40 chars; globally unique — created on first publish if you don't own it |
101
+ | `kind` | yes | enum | `game` \| `asset` \| `tool` |
102
+ | `entry` | yes | string | path of the entry HTML file inside the directory (must end in `.html` and exist) |
103
+ | `engine` | no | string | e.g. `vanilla-js`, `phaser`, `godot-web` |
104
+ | `summary` | no | string | short description shown on the project page |
105
+ | `tags` | no | string[] | max 5 tags, each ≤ 40 chars |
106
+ | `projectId` | auto | uuid | written by the CLI after the first publish; makes republish idempotent. Remove it to publish as a new project. |
107
+
108
+ ## Authentication and key scopes
109
+
110
+ Keys are issued in the gamit.ai dashboard (`gmt_live_…`, scoped Bearer keys).
111
+ `gamit publish` needs **write** (create spaces/projects, upload builds) and
112
+ **publish** (set status to published). `gamit login` works with any scope.
113
+
114
+ Key resolution order: `GAMIT_API_KEY` env var > `~/.gamit/config.json`
115
+ (override the directory with `GAMIT_CONFIG_DIR`).
116
+
117
+ ## Self-hosted / dev servers
118
+
119
+ Every command accepts `--api-base <url>` (or set `GAMIT_API_BASE`):
120
+
121
+ ```bash
122
+ gamit publish --api-base http://localhost:3000
123
+ ```
124
+
125
+ ## Security notes
126
+
127
+ - `gamit login --key gmt_live_…` puts the key in your shell history — prefer
128
+ the interactive prompt or `GAMIT_API_KEY`.
129
+ - The interactive prompt does **not** hide input in v1 (no tty masking).
130
+ - The config file is written with mode `0600`, but the key is stored in
131
+ plaintext — treat `~/.gamit/config.json` like a credential.
132
+ - Dotfiles (notably `.env*`) are never packed into builds.
package/dist/api.js ADDED
@@ -0,0 +1,118 @@
1
+ export class CliApiError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, status) {
5
+ super(`${code} (HTTP ${status})`);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = "CliApiError";
9
+ }
10
+ }
11
+ export async function apiFetch(path, { method = "GET", key, apiBase, json, form }) {
12
+ const headers = {};
13
+ if (key)
14
+ headers["Authorization"] = `Bearer ${key}`;
15
+ // The Bearer key is attached to every request — never send it over plaintext
16
+ // to a non-local origin (a hijacked --api-base / GAMIT_API_BASE could exfil it).
17
+ let base;
18
+ try {
19
+ base = new URL(apiBase);
20
+ }
21
+ catch {
22
+ throw new Error(`Invalid API base: ${apiBase}`);
23
+ }
24
+ const isLocal = base.hostname === "localhost" || base.hostname === "127.0.0.1";
25
+ if (base.protocol !== "https:" && !isLocal) {
26
+ throw new Error(`Refusing to send API key over insecure ${base.protocol}// to ${base.host} — use https://`);
27
+ }
28
+ let body;
29
+ if (json !== undefined) {
30
+ headers["Content-Type"] = "application/json";
31
+ body = JSON.stringify(json);
32
+ }
33
+ else if (form !== undefined) {
34
+ // Let fetch set the multipart boundary automatically
35
+ body = form;
36
+ }
37
+ let response;
38
+ try {
39
+ response = await fetch(`${apiBase}${path}`, {
40
+ method,
41
+ headers,
42
+ body,
43
+ });
44
+ }
45
+ catch (cause) {
46
+ const err = new Error(`Could not reach ${apiBase}`);
47
+ err.cause = cause;
48
+ throw err;
49
+ }
50
+ if (response.status === 204)
51
+ return null;
52
+ let parsed;
53
+ try {
54
+ parsed = await response.json();
55
+ }
56
+ catch {
57
+ parsed = {};
58
+ }
59
+ if (!response.ok) {
60
+ const code = parsed?.error ??
61
+ `http_${response.status}`;
62
+ throw new CliApiError(code, response.status);
63
+ }
64
+ return parsed;
65
+ }
66
+ export function friendly(err) {
67
+ if (err instanceof CliApiError) {
68
+ switch (err.code) {
69
+ case "unauthorized":
70
+ return "Invalid or missing API key — run `gamit login`";
71
+ case "missing_scope":
72
+ return "This key lacks the required scope (need write + publish)";
73
+ case "rate_limited":
74
+ return "Rate limited — try again later";
75
+ case "invalid_body":
76
+ return "The server rejected the request body (invalid_body) — check gamit.json field values";
77
+ case "slug_taken":
78
+ return "That slug is already taken (slug_taken)";
79
+ case "invalid_slug":
80
+ return "Invalid slug — lowercase letters, digits, hyphens only";
81
+ case "space_not_found":
82
+ return "Space not found (or not owned by this key)";
83
+ case "project_not_found":
84
+ return "Project not found — it may have been deleted or belongs to another account";
85
+ case "forbidden":
86
+ return "You don't own that resource";
87
+ case "payload_too_large":
88
+ return "Build zip exceeds the upload limit (max 4MB zipped)";
89
+ case "zip_too_large":
90
+ return "Build zip exceeds the server's 4MB zip limit";
91
+ case "build_too_large":
92
+ return "Build is too large when unzipped (server limit exceeded)";
93
+ case "too_many_files":
94
+ return "Build has too many files (server limit 500)";
95
+ case "zip_invalid":
96
+ return "The uploaded zip could not be read — re-run `gamit publish`";
97
+ case "no_entry_html":
98
+ return "The server found no entry HTML in the build — make sure index.html sits at the directory root";
99
+ case "missing_file":
100
+ return "Upload reached the server without a file part — re-run `gamit publish` (and report this if it persists)";
101
+ case "paid_project_needs_price":
102
+ return "Paid projects need a price before publishing — set priceCents via the API or dashboard";
103
+ default:
104
+ return `Server said: ${err.code} (HTTP ${err.status})`;
105
+ }
106
+ }
107
+ if (err instanceof Error && err.message.startsWith("Could not reach ")) {
108
+ return err.message;
109
+ }
110
+ if (err instanceof Error) {
111
+ return err.message;
112
+ }
113
+ return String(err);
114
+ }
115
+ export function fail(err) {
116
+ process.stderr.write(`✖ ${friendly(err)}\n`);
117
+ process.exit(1);
118
+ }
@@ -0,0 +1,167 @@
1
+ import { createInterface } from "node:readline";
2
+ import { basename, resolve } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { readManifest, writeManifest, slugify } from "../manifest.js";
5
+ /**
6
+ * Node 26 readline behavior: when stdin hits EOF, the readline interface fires
7
+ * 'close' immediately — even if there are buffered lines not yet consumed by
8
+ * rl.question() callbacks (which only fire after the prompt is written).
9
+ *
10
+ * Fix: listen for 'line' events eagerly and queue them; drain the queue when
11
+ * ask() is called. Falls back to waiting for the next 'line' if the queue is
12
+ * empty and stdin is still open.
13
+ */
14
+ const MAX_RETRIES = 10;
15
+ function makePrompter(input, output) {
16
+ const queue = [];
17
+ let waitingResolve = null;
18
+ let waitingReject = null;
19
+ let closed = false;
20
+ const rl = createInterface({ input, output, terminal: false });
21
+ rl.on("line", (line) => {
22
+ if (waitingResolve) {
23
+ const resolve = waitingResolve;
24
+ waitingResolve = null;
25
+ waitingReject = null;
26
+ resolve(line.trim());
27
+ }
28
+ else {
29
+ queue.push(line.trim());
30
+ }
31
+ });
32
+ rl.on("close", () => {
33
+ closed = true;
34
+ if (waitingReject) {
35
+ const reject = waitingReject;
36
+ waitingResolve = null;
37
+ waitingReject = null;
38
+ reject(new Error("stdin closed before prompts completed"));
39
+ }
40
+ });
41
+ function ask(prompt) {
42
+ output.write(prompt);
43
+ if (queue.length > 0) {
44
+ return Promise.resolve(queue.shift());
45
+ }
46
+ if (closed) {
47
+ return Promise.reject(new Error("stdin closed before prompts completed"));
48
+ }
49
+ return new Promise((resolve, reject) => {
50
+ waitingResolve = resolve;
51
+ waitingReject = reject;
52
+ });
53
+ }
54
+ function close() {
55
+ rl.close();
56
+ }
57
+ return { ask, close };
58
+ }
59
+ const VALID_KINDS = ["game", "asset", "tool"];
60
+ export async function initAction(dir, options) {
61
+ const absDir = resolve(dir);
62
+ // Check for existing gamit.json
63
+ if (!options.force) {
64
+ try {
65
+ readManifest(absDir);
66
+ // If we reach here, the file exists and is valid
67
+ process.stderr.write("✖ gamit.json already exists (use --force to overwrite)\n");
68
+ process.exit(1);
69
+ }
70
+ catch (err) {
71
+ // "No gamit.json found" means we can proceed; any other error means it
72
+ // exists but is malformed — still block unless --force
73
+ if (err instanceof Error &&
74
+ !err.message.startsWith("No gamit.json found")) {
75
+ const jsonPath = `${absDir}/gamit.json`;
76
+ if (existsSync(jsonPath)) {
77
+ process.stderr.write("✖ gamit.json already exists (use --force to overwrite)\n");
78
+ process.exit(1);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ const prompter = makePrompter(process.stdin, process.stderr);
84
+ try {
85
+ // title
86
+ const dirName = basename(absDir);
87
+ let title;
88
+ for (let i = 0; i < MAX_RETRIES; i++) {
89
+ const titleInput = await prompter.ask(`title [${dirName}]: `);
90
+ const candidate = titleInput || dirName;
91
+ if (candidate.length <= 200) {
92
+ title = candidate;
93
+ break;
94
+ }
95
+ process.stderr.write(" title must be 200 characters or fewer\n");
96
+ if (i === MAX_RETRIES - 1) {
97
+ throw new Error("Too many invalid title attempts");
98
+ }
99
+ }
100
+ title = title;
101
+ // slug
102
+ const defaultSlug = slugify(title);
103
+ let slug;
104
+ for (let i = 0; i < MAX_RETRIES; i++) {
105
+ const slugInput = await prompter.ask(`slug [${defaultSlug}]: `);
106
+ const candidate = slugInput || defaultSlug;
107
+ if (/^[a-z0-9-]{1,60}$/.test(candidate)) {
108
+ slug = candidate;
109
+ break;
110
+ }
111
+ process.stderr.write(" slug must match /^[a-z0-9-]{1,60}$/ (lowercase letters, digits, hyphens)\n");
112
+ if (i === MAX_RETRIES - 1) {
113
+ throw new Error("Too many invalid slug attempts");
114
+ }
115
+ }
116
+ slug = slug;
117
+ // spaceSlug
118
+ let spaceSlug;
119
+ for (let i = 0; i < MAX_RETRIES; i++) {
120
+ const input = await prompter.ask("spaceSlug (your space slug on gamit.ai — create one at gamit.ai/dashboard): ");
121
+ if (/^[a-z0-9-]{3,40}$/.test(input)) {
122
+ spaceSlug = input;
123
+ break;
124
+ }
125
+ process.stderr.write(" spaceSlug must match /^[a-z0-9-]{3,40}$/ (lowercase letters, digits, hyphens; 3–40 chars)\n");
126
+ if (i === MAX_RETRIES - 1) {
127
+ throw new Error("Too many invalid spaceSlug attempts");
128
+ }
129
+ }
130
+ spaceSlug = spaceSlug;
131
+ // kind
132
+ const kindInput = await prompter.ask("kind [game]: ");
133
+ const kindRaw = kindInput || "game";
134
+ const kind = VALID_KINDS.includes(kindRaw)
135
+ ? kindRaw
136
+ : "game";
137
+ if (kindInput && !VALID_KINDS.includes(kindInput)) {
138
+ process.stderr.write(` Unknown kind "${kindInput}", using "game"\n`);
139
+ }
140
+ // entry
141
+ const entryInput = await prompter.ask("entry [index.html]: ");
142
+ const entry = entryInput || "index.html";
143
+ if (!existsSync(`${absDir}/${entry}`)) {
144
+ process.stderr.write(`⚠ ${entry} not found in ${absDir} — create it before publishing\n`);
145
+ }
146
+ // engine (optional)
147
+ const engineInput = await prompter.ask("engine []: ");
148
+ const engine = engineInput || undefined;
149
+ prompter.close();
150
+ const manifest = {
151
+ title,
152
+ slug,
153
+ spaceSlug,
154
+ kind,
155
+ entry,
156
+ ...(engine !== undefined && { engine }),
157
+ };
158
+ writeManifest(absDir, manifest);
159
+ process.stdout.write("✔ Wrote gamit.json\n");
160
+ const publishArg = dir === "." ? "" : ` ${dir}`;
161
+ process.stdout.write(`Next: gamit publish${publishArg}\n`);
162
+ }
163
+ catch (err) {
164
+ prompter.close();
165
+ throw err;
166
+ }
167
+ }
@@ -0,0 +1,43 @@
1
+ import { createInterface } from "node:readline";
2
+ import { loadConfig, saveConfig } from "../config.js";
3
+ import { apiFetch, fail } from "../api.js";
4
+ async function promptApiKey() {
5
+ return new Promise((resolve) => {
6
+ const rl = createInterface({
7
+ input: process.stdin,
8
+ output: process.stderr,
9
+ });
10
+ rl.on("close", () => {
11
+ resolve("");
12
+ });
13
+ rl.question("Paste your gamit API key (gmt_live_…): ", (answer) => {
14
+ rl.close();
15
+ resolve(answer.trim());
16
+ });
17
+ });
18
+ }
19
+ export async function loginAction(options) {
20
+ const config = loadConfig();
21
+ const apiBase = options.apiBase ?? config.apiBase;
22
+ const apiKey = options.key ?? (await promptApiKey());
23
+ if (!apiKey) {
24
+ fail(new Error("No API key provided"));
25
+ }
26
+ // Validate by calling GET /api/v1/me
27
+ let result;
28
+ try {
29
+ result = await apiFetch("/api/v1/me", { apiBase, key: apiKey });
30
+ }
31
+ catch (err) {
32
+ fail(err);
33
+ }
34
+ // Save config
35
+ const toSave = { apiKey };
36
+ if (options.apiBase)
37
+ toSave.apiBase = options.apiBase;
38
+ saveConfig(toSave);
39
+ const profile = result
40
+ ?.data;
41
+ const identity = profile?.display_name ?? profile?.username ?? "(unknown)";
42
+ process.stdout.write(`✔ Logged in as ${identity}\n`);
43
+ }
@@ -0,0 +1,5 @@
1
+ import { clearApiKey } from "../config.js";
2
+ export async function logoutAction() {
3
+ const removed = clearApiKey();
4
+ process.stdout.write(removed ? "✔ Logged out (API key removed)\n" : "✔ Not logged in — nothing to do\n");
5
+ }
@@ -0,0 +1,174 @@
1
+ import { resolve } from "node:path";
2
+ import { loadConfig } from "../config.js";
3
+ import { apiFetch, fail, CliApiError } from "../api.js";
4
+ import { readManifest, writeManifest } from "../manifest.js";
5
+ import { packDir } from "../zip.js";
6
+ function progress(msg) {
7
+ process.stderr.write(`${msg}\n`);
8
+ }
9
+ /**
10
+ * Resolve the manifest's spaceSlug to an owned space id: reuse it when the
11
+ * key's owner already has it (GET /api/v1/spaces lists own spaces only),
12
+ * otherwise create it. A slug_taken on create therefore means the slug
13
+ * belongs to ANOTHER user — space slugs are globally unique.
14
+ */
15
+ async function ensureSpaceId(manifest, auth) {
16
+ let owned;
17
+ try {
18
+ const res = (await apiFetch("/api/v1/spaces", auth));
19
+ owned = res.data;
20
+ }
21
+ catch (err) {
22
+ fail(err);
23
+ }
24
+ const existing = owned.find((s) => s.slug === manifest.spaceSlug);
25
+ if (existing)
26
+ return existing.id;
27
+ try {
28
+ const res = (await apiFetch("/api/v1/spaces", {
29
+ ...auth,
30
+ method: "POST",
31
+ json: { slug: manifest.spaceSlug, name: manifest.spaceSlug },
32
+ }));
33
+ progress(`• created space "${manifest.spaceSlug}"`);
34
+ return res.data.id;
35
+ }
36
+ catch (err) {
37
+ if (err instanceof CliApiError && err.code === "slug_taken") {
38
+ fail(new Error(`Space slug "${manifest.spaceSlug}" belongs to another user — pick a different spaceSlug in gamit.json`));
39
+ }
40
+ fail(err);
41
+ }
42
+ }
43
+ /**
44
+ * Create the project, or — when its slug is already taken inside our own
45
+ * space (slugs are unique per space, and we just verified the space is
46
+ * ours) — look the existing project up via GET /api/v1/projects?spaceId=…
47
+ * and reuse its id.
48
+ */
49
+ async function ensureProjectId(manifest, spaceId, auth) {
50
+ // gamit.json (minus projectId) doubles as the project's ai_manifest
51
+ const aiManifest = { ...manifest };
52
+ delete aiManifest.projectId;
53
+ try {
54
+ const res = (await apiFetch("/api/v1/projects", {
55
+ ...auth,
56
+ method: "POST",
57
+ json: {
58
+ spaceId,
59
+ slug: manifest.slug,
60
+ title: manifest.title,
61
+ kind: manifest.kind,
62
+ ...(manifest.summary !== undefined && { summary: manifest.summary }),
63
+ ...(manifest.tags !== undefined && { tags: manifest.tags }),
64
+ aiManifest,
65
+ },
66
+ }));
67
+ progress(`• created project ${manifest.spaceSlug}/${manifest.slug}`);
68
+ return res.data.id;
69
+ }
70
+ catch (err) {
71
+ if (!(err instanceof CliApiError) || err.code !== "slug_taken")
72
+ fail(err);
73
+ }
74
+ let owned;
75
+ try {
76
+ const res = (await apiFetch(`/api/v1/projects?spaceId=${spaceId}`, auth));
77
+ owned = res.data;
78
+ }
79
+ catch (err) {
80
+ fail(err);
81
+ }
82
+ const existing = owned.find((p) => p.slug === manifest.slug);
83
+ if (!existing) {
84
+ fail(new Error(`Slug "${manifest.slug}" is taken in space "${manifest.spaceSlug}" but the project could not be listed — add its projectId to gamit.json manually`));
85
+ }
86
+ progress(`• reusing existing project ${manifest.spaceSlug}/${manifest.slug}`);
87
+ return existing.id;
88
+ }
89
+ export async function publishAction(dir, options) {
90
+ const absDir = resolve(dir);
91
+ // 1. manifest + config
92
+ let manifest;
93
+ try {
94
+ manifest = readManifest(absDir);
95
+ }
96
+ catch (err) {
97
+ fail(err);
98
+ }
99
+ const config = loadConfig();
100
+ const apiBase = options.apiBase ?? config.apiBase;
101
+ if (!config.apiKey) {
102
+ fail(new Error("No API key found — run `gamit login` first (or set GAMIT_API_KEY)"));
103
+ }
104
+ const auth = { apiBase, key: config.apiKey };
105
+ // 2. pack before touching the network
106
+ let zip;
107
+ try {
108
+ zip = packDir(absDir, manifest.entry);
109
+ }
110
+ catch (err) {
111
+ fail(err);
112
+ }
113
+ progress(`• packed ${zip.fileCount} file(s) (${(zip.data.byteLength / 1024).toFixed(1)}KB zipped)`);
114
+ // 3. resolve the project id
115
+ let projectId;
116
+ let spaceSlug = manifest.spaceSlug;
117
+ let projectSlug = manifest.slug;
118
+ if (manifest.projectId) {
119
+ projectId = manifest.projectId;
120
+ try {
121
+ const res = (await apiFetch(`/api/v1/projects/${projectId}`, auth));
122
+ // Server truth for the final URL (handles renames since first publish)
123
+ projectSlug = res.data.slug ?? projectSlug;
124
+ spaceSlug = res.data.spaces?.slug ?? spaceSlug;
125
+ }
126
+ catch (err) {
127
+ if (err instanceof CliApiError && err.code === "project_not_found") {
128
+ fail(new Error("projectId in gamit.json no longer exists — remove it and re-run `gamit publish`"));
129
+ }
130
+ fail(err);
131
+ }
132
+ }
133
+ else {
134
+ const spaceId = await ensureSpaceId(manifest, auth);
135
+ projectId = await ensureProjectId(manifest, spaceId, auth);
136
+ }
137
+ // 4. upload the build (multipart: file=<zip>)
138
+ const form = new FormData();
139
+ // fflate types its output as Uint8Array<ArrayBufferLike>; Blob wants ArrayBuffer-backed
140
+ const zipBytes = zip.data;
141
+ form.append("file", new Blob([zipBytes], { type: "application/zip" }), "build.zip");
142
+ if (options.notes)
143
+ form.append("notes", options.notes);
144
+ let build;
145
+ try {
146
+ const res = (await apiFetch(`/api/v1/projects/${projectId}/builds`, {
147
+ ...auth,
148
+ method: "POST",
149
+ form,
150
+ }));
151
+ build = res.data;
152
+ }
153
+ catch (err) {
154
+ fail(err);
155
+ }
156
+ progress(`• uploaded build ${build.version} (entry ${build.entryHtml})`);
157
+ // 5. go live
158
+ try {
159
+ await apiFetch(`/api/v1/projects/${projectId}`, {
160
+ ...auth,
161
+ method: "PATCH",
162
+ json: { status: "published" },
163
+ });
164
+ }
165
+ catch (err) {
166
+ fail(err);
167
+ }
168
+ // 6. persist projectId so the next publish is an idempotent republish
169
+ writeManifest(absDir, { ...manifest, projectId });
170
+ process.stdout.write(`✔ Published → ${apiBase}/p/${spaceSlug}/${projectSlug}\n`);
171
+ if (manifest.kind === "game") {
172
+ process.stdout.write(` Play: ${build.playUrl}\n`);
173
+ }
174
+ }
package/dist/config.js ADDED
@@ -0,0 +1,57 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
4
+ const DEFAULT_BASE = "https://gamit.ai";
5
+ function configDir() {
6
+ return process.env.GAMIT_CONFIG_DIR ?? join(homedir(), ".gamit");
7
+ }
8
+ function configPath() {
9
+ return join(configDir(), "config.json");
10
+ }
11
+ function readFileJson() {
12
+ try {
13
+ const raw = readFileSync(configPath(), "utf-8");
14
+ return JSON.parse(raw);
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function loadConfig() {
21
+ const file = readFileJson();
22
+ const apiKey = process.env.GAMIT_API_KEY ?? file.apiKey;
23
+ const apiBase = process.env.GAMIT_API_BASE ?? file.apiBase ?? DEFAULT_BASE;
24
+ return { apiKey, apiBase };
25
+ }
26
+ export function saveConfig(partial) {
27
+ const existing = readFileJson();
28
+ const merged = { ...existing, ...partial };
29
+ const dir = configDir();
30
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ const path = configPath();
32
+ writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
33
+ // ensure mode is 0600 even if file already existed
34
+ try {
35
+ chmodSync(path, 0o600);
36
+ }
37
+ catch {
38
+ // best-effort on platforms that don't support chmod
39
+ }
40
+ }
41
+ export function clearApiKey() {
42
+ const existing = readFileJson();
43
+ if (existing.apiKey === undefined)
44
+ return false; // idempotent
45
+ delete existing.apiKey;
46
+ const dir = configDir();
47
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ const path = configPath();
49
+ writeFileSync(path, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
50
+ try {
51
+ chmodSync(path, 0o600);
52
+ }
53
+ catch {
54
+ // best-effort on platforms that don't support chmod
55
+ }
56
+ return true;
57
+ }
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "commander";
3
+ import { loginAction } from "./commands/login.js";
4
+ import { logoutAction } from "./commands/logout.js";
5
+ import { initAction } from "./commands/init.js";
6
+ import { publishAction } from "./commands/publish.js";
7
+ program
8
+ .name("gamit")
9
+ .description("Publish vibe-coded games to gamit.ai")
10
+ .version("0.1.0");
11
+ program
12
+ .command("login")
13
+ .description("Authenticate with your gamit.ai API key")
14
+ .option("--key <key>", "API key (gmt_live_…)")
15
+ .option("--api-base <url>", "Override API base URL")
16
+ .action(async (options) => {
17
+ await loginAction(options).catch((err) => {
18
+ process.stderr.write(`✖ ${err instanceof Error ? err.message : String(err)}\n`);
19
+ process.exit(1);
20
+ });
21
+ });
22
+ program
23
+ .command("logout")
24
+ .description("Remove the stored API key from ~/.gamit/config.json")
25
+ .action(async () => {
26
+ await logoutAction().catch((err) => {
27
+ process.stderr.write(`✖ ${err instanceof Error ? err.message : String(err)}\n`);
28
+ process.exit(1);
29
+ });
30
+ });
31
+ program
32
+ .command("init [dir]")
33
+ .description("Scaffold a gamit.json manifest in the given directory (default .)")
34
+ .option("--force", "Overwrite existing gamit.json")
35
+ .action(async (dir, options) => {
36
+ await initAction(dir ?? ".", options).catch((err) => {
37
+ process.stderr.write(`✖ ${err instanceof Error ? err.message : String(err)}\n`);
38
+ process.exit(1);
39
+ });
40
+ });
41
+ program
42
+ .command("publish [dir]")
43
+ .description("Package and publish a game build to gamit.ai (default dir .)")
44
+ .option("--api-base <url>", "Override API base URL")
45
+ .option("--notes <text>", "Release/patch notes posted to the game's timeline thread")
46
+ .action(async (dir, options) => {
47
+ await publishAction(dir ?? ".", options).catch((err) => {
48
+ process.stderr.write(`✖ ${err instanceof Error ? err.message : String(err)}\n`);
49
+ process.exit(1);
50
+ });
51
+ });
52
+ program.parse();
@@ -0,0 +1,149 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const SLUG_RE = /^[a-z0-9-]{1,60}$/;
4
+ const SPACE_SLUG_RE = /^[a-z0-9-]{3,40}$/;
5
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const VALID_KINDS = ["game", "asset", "tool"];
7
+ function assertString(value, field) {
8
+ if (typeof value !== "string") {
9
+ throw new Error(`${field}: must be a string`);
10
+ }
11
+ return value;
12
+ }
13
+ function validateManifest(raw) {
14
+ if (typeof raw !== "object" || raw === null) {
15
+ throw new Error("gamit.json must be a JSON object");
16
+ }
17
+ const obj = raw;
18
+ // title: 1..200 string
19
+ const title = assertString(obj.title, "title");
20
+ if (title.length < 1 || title.length > 200) {
21
+ throw new Error("title: must be 1–200 characters");
22
+ }
23
+ // slug: /^[a-z0-9-]{1,60}$/
24
+ const slug = assertString(obj.slug, "slug");
25
+ if (!SLUG_RE.test(slug)) {
26
+ throw new Error("slug: must match /^[a-z0-9-]{1,60}$/ (lowercase letters, digits, hyphens; 1–60 chars)");
27
+ }
28
+ // spaceSlug: /^[a-z0-9-]{3,40}$/
29
+ const spaceSlug = assertString(obj.spaceSlug, "spaceSlug");
30
+ if (!SPACE_SLUG_RE.test(spaceSlug)) {
31
+ throw new Error("spaceSlug: must match /^[a-z0-9-]{3,40}$/ (lowercase letters, digits, hyphens; 3–40 chars)");
32
+ }
33
+ // kind: enum
34
+ const kind = assertString(obj.kind, "kind");
35
+ if (!VALID_KINDS.includes(kind)) {
36
+ throw new Error(`kind: must be one of ${VALID_KINDS.join(", ")}`);
37
+ }
38
+ // entry: non-empty string ending .html
39
+ const entry = assertString(obj.entry, "entry");
40
+ if (!entry.endsWith(".html")) {
41
+ throw new Error("entry: must be a non-empty string ending in .html");
42
+ }
43
+ if (entry.length === 0) {
44
+ throw new Error("entry: must not be empty");
45
+ }
46
+ // tags: optional string[] <=5 items each <=40 chars
47
+ let tags;
48
+ if (obj.tags !== undefined) {
49
+ if (!Array.isArray(obj.tags)) {
50
+ throw new Error("tags: must be an array of strings");
51
+ }
52
+ if (obj.tags.length > 5) {
53
+ throw new Error("tags: must have at most 5 items");
54
+ }
55
+ for (const tag of obj.tags) {
56
+ if (typeof tag !== "string") {
57
+ throw new Error("tags: each item must be a string");
58
+ }
59
+ if (tag.length > 40) {
60
+ throw new Error(`tags: each tag must be at most 40 characters (got "${tag}")`);
61
+ }
62
+ }
63
+ tags = obj.tags;
64
+ }
65
+ // projectId: optional uuid-shaped
66
+ let projectId;
67
+ if (obj.projectId !== undefined) {
68
+ const pid = assertString(obj.projectId, "projectId");
69
+ if (!UUID_RE.test(pid)) {
70
+ throw new Error("projectId: must be a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)");
71
+ }
72
+ projectId = pid;
73
+ }
74
+ // optional string fields
75
+ const engine = obj.engine !== undefined ? assertString(obj.engine, "engine") : undefined;
76
+ const summary = obj.summary !== undefined
77
+ ? assertString(obj.summary, "summary")
78
+ : undefined;
79
+ return {
80
+ title,
81
+ slug,
82
+ spaceSlug,
83
+ kind: kind,
84
+ entry,
85
+ ...(engine !== undefined && { engine }),
86
+ ...(summary !== undefined && { summary }),
87
+ ...(tags !== undefined && { tags }),
88
+ ...(projectId !== undefined && { projectId }),
89
+ };
90
+ }
91
+ export function readManifest(dir) {
92
+ const path = join(dir, "gamit.json");
93
+ let raw;
94
+ try {
95
+ raw = readFileSync(path, "utf-8");
96
+ }
97
+ catch {
98
+ throw new Error("No gamit.json found — run `gamit init` first.");
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(raw);
103
+ }
104
+ catch (err) {
105
+ throw new Error(`gamit.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
106
+ }
107
+ return validateManifest(parsed);
108
+ }
109
+ /** Stable key order for deterministic output. */
110
+ const KEY_ORDER = [
111
+ "title",
112
+ "slug",
113
+ "spaceSlug",
114
+ "kind",
115
+ "entry",
116
+ "engine",
117
+ "summary",
118
+ "tags",
119
+ "projectId",
120
+ ];
121
+ export function writeManifest(dir, m) {
122
+ const ordered = {};
123
+ for (const key of KEY_ORDER) {
124
+ if (m[key] !== undefined) {
125
+ ordered[key] = m[key];
126
+ }
127
+ }
128
+ const path = join(dir, "gamit.json");
129
+ writeFileSync(path, JSON.stringify(ordered, null, 2) + "\n", "utf-8");
130
+ }
131
+ /**
132
+ * Convert an arbitrary string to a valid slug.
133
+ * - Lowercased
134
+ * - Spaces and underscores → "-"
135
+ * - Non [a-z0-9-] characters stripped (this means non-latin script such as
136
+ * Hangul/CJK/Arabic/etc. is removed entirely — document this behavior)
137
+ * - Consecutive hyphens collapsed to one
138
+ * - Leading/trailing hyphens trimmed
139
+ * - Truncated to 60 characters
140
+ */
141
+ export function slugify(s) {
142
+ return s
143
+ .toLowerCase()
144
+ .replace(/[\s_]+/g, "-")
145
+ .replace(/[^a-z0-9-]/g, "")
146
+ .replace(/-{2,}/g, "-")
147
+ .replace(/^-+|-+$/g, "")
148
+ .slice(0, 60);
149
+ }
package/dist/zip.js ADDED
@@ -0,0 +1,76 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { zipSync } from "fflate";
4
+ /**
5
+ * The server caps build uploads at 4MB zipped (Vercel body-limit headroom).
6
+ * We enforce 4MB on the UNCOMPRESSED total before zipping — stricter, so a
7
+ * passing pack always uploads — plus a belt-and-braces check on the zip itself.
8
+ */
9
+ export const MAX_BYTES = 4 * 1024 * 1024;
10
+ const EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
11
+ function formatBytes(n) {
12
+ if (n >= 1024 * 1024)
13
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
14
+ if (n >= 1024)
15
+ return `${(n / 1024).toFixed(1)}KB`;
16
+ return `${n}B`;
17
+ }
18
+ /** Files we never ship: the manifest itself, dot-anything, nested zips. */
19
+ function isExcludedFile(name) {
20
+ return name === "gamit.json" || name.startsWith(".") || name.endsWith(".zip");
21
+ }
22
+ /**
23
+ * Recursively collect relative forward-slash file paths under `absDir`.
24
+ * Skips node_modules/.git and any dot-prefixed directory (.next, .vercel, …);
25
+ * dotfiles (.env*, .DS_Store, …) are excluded at every level.
26
+ */
27
+ function walk(absDir, rel, out) {
28
+ for (const ent of readdirSync(absDir, { withFileTypes: true })) {
29
+ const childRel = rel === "" ? ent.name : `${rel}/${ent.name}`;
30
+ if (ent.isDirectory()) {
31
+ if (EXCLUDED_DIRS.has(ent.name) || ent.name.startsWith("."))
32
+ continue;
33
+ walk(join(absDir, ent.name), childRel, out);
34
+ }
35
+ else if (ent.isFile()) {
36
+ if (isExcludedFile(ent.name))
37
+ continue;
38
+ out.push(childRel);
39
+ }
40
+ // symlinks/sockets/etc. are silently skipped
41
+ }
42
+ }
43
+ /**
44
+ * Zip a game directory for upload.
45
+ * Throws (with a friendly message) when the entry file is missing from the
46
+ * packed set, the dir is empty after exclusions, or the build exceeds 4MB.
47
+ */
48
+ export function packDir(dir, entry) {
49
+ const paths = [];
50
+ walk(dir, "", paths);
51
+ paths.sort();
52
+ if (paths.length === 0) {
53
+ throw new Error(`Nothing to pack in ${dir} — no files left after exclusions (dotfiles, node_modules, gamit.json, *.zip)`);
54
+ }
55
+ const normalizedEntry = entry.replaceAll("\\", "/");
56
+ if (!paths.includes(normalizedEntry)) {
57
+ throw new Error(`Entry file "${entry}" not found in ${dir}`);
58
+ }
59
+ // Sum actual sizes first so the error reports the real total.
60
+ let totalBytes = 0;
61
+ for (const rel of paths) {
62
+ totalBytes += statSync(join(dir, ...rel.split("/"))).size;
63
+ }
64
+ if (totalBytes > MAX_BYTES) {
65
+ throw new Error(`Build is ${formatBytes(totalBytes)} uncompressed (${paths.length} files) — exceeds the 4MB upload limit. Trim assets or remove files.`);
66
+ }
67
+ const tree = {};
68
+ for (const rel of paths) {
69
+ tree[rel] = new Uint8Array(readFileSync(join(dir, ...rel.split("/"))));
70
+ }
71
+ const data = zipSync(tree);
72
+ if (data.byteLength > MAX_BYTES) {
73
+ throw new Error(`Zipped build is ${formatBytes(data.byteLength)} — exceeds the 4MB upload limit`);
74
+ }
75
+ return { data, fileCount: paths.length };
76
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "gamit",
3
+ "version": "0.1.0",
4
+ "description": "Publish vibe-coded games to gamit.ai",
5
+ "keywords": ["gamit", "games", "publish", "cli", "vibe-coding", "webgames", "itch"],
6
+ "homepage": "https://gamit.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sonan0721/gamit.git",
10
+ "directory": "cli"
11
+ },
12
+ "bugs": { "url": "https://github.com/sonan0721/gamit/issues" },
13
+ "type": "module",
14
+ "bin": { "gamit": "./dist/index.js" },
15
+ "files": ["dist"],
16
+ "engines": { "node": ">=18.17" },
17
+ "license": "MIT",
18
+ "publishConfig": { "access": "public" },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "publint": "publint",
23
+ "verify": "pnpm run build && pnpm run test && pnpm run publint && node scripts/check-pack.mjs && node scripts/audit-deps.mjs && node scripts/smoke-tarball.mjs",
24
+ "prepublishOnly": "pnpm run verify"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^12.1.0",
28
+ "fflate": "^0.8.3"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.0.0",
32
+ "publint": "^0.3.21",
33
+ "typescript": "^5",
34
+ "vitest": "^4.1.8"
35
+ }
36
+ }