pixelforge-uploader 2.0.0 → 2.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 (3) hide show
  1. package/README.md +21 -7
  2. package/package.json +1 -1
  3. package/src.js +346 -41
package/README.md CHANGED
@@ -22,22 +22,36 @@ Needs [Node.js](https://nodejs.org) 18+ (one-time install). Then, in a terminal:
22
22
  npx pixelforge-uploader
23
23
  ```
24
24
 
25
- The first time, it asks for your Open Cloud API key and creator id, then prints
26
- "PixelForge Uploader is running". Leave it open while you upload; close the
27
- window when you're done. Same command on Windows and macOS.
25
+ The first time, it walks you through a short setup (your API key and where to
26
+ upload), then shows a small status screen. Leave it open while you upload; close
27
+ the window when you're done. Same command on Windows and macOS.
28
28
 
29
29
  ## First-run setup
30
30
 
31
- It asks for:
31
+ A guided wizard asks for:
32
32
 
33
33
  - **API key** - an Open Cloud key with the `asset:write` scope. Create one at
34
34
  https://create.roblox.com/dashboard/credentials (add the "asset" API, check
35
35
  `asset:write`, set IP Address to `0.0.0.0/0`).
36
36
  - **Creator** - upload to your account (user) or a group, and the numeric id.
37
37
 
38
- These are saved to `~/.pixelforge-uploader/config.json` (permissions 600). To
39
- change them later run `npx pixelforge-uploader reconfigure`, or set the env vars
40
- `PIXELFORGE_API_KEY`, `PIXELFORGE_CREATOR_TYPE`, `PIXELFORGE_CREATOR_ID`.
38
+ These are saved to `~/.pixelforge-uploader/config.json` (permissions 600).
39
+
40
+ ## The status screen
41
+
42
+ While it's running it shows whether it's connected, where icons upload, and your
43
+ recent uploads. Press a key any time to change things, no restart needed:
44
+
45
+ - `k` - change your API key
46
+ - `c` - switch between your account and a group
47
+ - `t` - test your API key (a quick check that doesn't upload anything)
48
+ - `o` - open the settings folder
49
+ - `r` - redo the whole setup
50
+ - `q` - quit
51
+
52
+ You can also re-run setup with `npx pixelforge-uploader reconfigure`, or set the
53
+ env vars `PIXELFORGE_API_KEY`, `PIXELFORGE_CREATOR_TYPE`, `PIXELFORGE_CREATOR_ID`
54
+ (handy for scripts; this skips the status screen).
41
55
 
42
56
  ## Use it
43
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixelforge-uploader",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Local helper that uploads PixelForge icons to Roblox via Open Cloud. The Studio plugin sends raw pixels over localhost; this app holds the API key, encodes the PNG, and uploads.",
5
5
  "license": "MIT",
6
6
  "engines": { "node": ">=18" },
package/src.js CHANGED
@@ -10,14 +10,18 @@
10
10
  // user's own machine. The plugin itself stores no key and references no Roblox
11
11
  // API - it only POSTs raw pixels to localhost.
12
12
  //
13
- // Zero dependencies: plain Node http/https/zlib.
13
+ // Zero dependencies: plain Node http/https/zlib (+ readline/child_process for
14
+ // the friendly terminal UI). The UI is hand-rolled ANSI so there's nothing to
15
+ // npm-install: `npx pixelforge-uploader` just runs.
14
16
  //
15
17
  // Config (API key + creator) is collected once, interactively, and saved to
16
18
  // ~/.pixelforge-uploader/config.json
17
19
  // (override with env PIXELFORGE_API_KEY / PIXELFORGE_CREATOR_TYPE /
18
- // PIXELFORGE_CREATOR_ID; re-run setup with `node src.js reconfigure`).
20
+ // PIXELFORGE_CREATOR_ID; re-run setup with `node src.js reconfigure`). When
21
+ // running in a real terminal it shows a live dashboard with single-key commands
22
+ // to change the key, switch creator, test the key, etc. without restarting.
19
23
  //
20
- // Transport from the plugin:
24
+ // Transport from the plugin (UNCHANGED - the plugin depends on this exactly):
21
25
  // POST http://127.0.0.1:<PORT>/upload
22
26
  // headers: x-pf-width, x-pf-height, x-pf-name (plain text)
23
27
  // body: raw RGBA bytes (width*height*4)
@@ -41,6 +45,58 @@ const ASSETS_URL = "https://apis.roblox.com/assets/v1/assets";
41
45
  const OPERATION_URL = "https://apis.roblox.com/assets/v1/operations/";
42
46
  const POLL_INTERVAL_MS = 1500;
43
47
  const POLL_MAX_ATTEMPTS = 30;
48
+ const CREDENTIALS_URL = "https://create.roblox.com/dashboard/credentials";
49
+
50
+ // ---------- terminal styling (zero-dep ANSI) ----------
51
+
52
+ // Only style when we're attached to a real terminal, and honour NO_COLOR.
53
+ const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
54
+ const C = {
55
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
56
+ green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m",
57
+ cyan: "\x1b[36m", gray: "\x1b[90m", orange: "\x1b[38;5;214m",
58
+ };
59
+ const CLEAR = "\x1b[2J\x1b[3J\x1b[H"; // clear screen + scrollback + home
60
+ const HIDE_CURSOR = "\x1b[?25l";
61
+ const SHOW_CURSOR = "\x1b[?25h";
62
+
63
+ const paint = (code, s) => (useColor ? code + s + C.reset : String(s));
64
+ const col = (name, s) => paint(C[name] || "", s);
65
+ const bold = (s) => paint(C.bold, s);
66
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
67
+
68
+ function termWidth() {
69
+ return Math.max(44, Math.min((process.stdout.columns || 60) - 2, 66));
70
+ }
71
+
72
+ function maskKey(k) {
73
+ if (!k) return "(none yet)";
74
+ const s = String(k);
75
+ return s.length <= 4 ? "••••" : "••••••••" + s.slice(-4);
76
+ }
77
+
78
+ function creatorPhrase(cfg) {
79
+ if (!cfg) return "(not set)";
80
+ return (cfg.creatorType === "group" ? "group " : "your account ") + cfg.creatorId;
81
+ }
82
+
83
+ // ---------- runtime state ----------
84
+
85
+ let cfg = null; // current config (may be swapped live from the menu)
86
+ let server = null;
87
+ let interactive = false; // true only when both stdin and stdout are a TTY
88
+ let uiMode = "plain"; // "plain" | "menu" | "busy" | "prompt"
89
+ let uploadCount = 0;
90
+ const logLines = []; // recent activity shown in the dashboard
91
+
92
+ function log(msg) {
93
+ const ts = new Date().toTimeString().slice(0, 5);
94
+ logLines.push(col("gray", ts) + " " + msg);
95
+ while (logLines.length > 8) logLines.shift();
96
+ if (uiMode === "menu" || uiMode === "busy") renderDashboard();
97
+ else if (!interactive) console.log(stripAnsi(ts + " " + msg));
98
+ // uiMode === "prompt": hold off, the prompt owns the screen; it renders after.
99
+ }
44
100
 
45
101
  // ---------- config ----------
46
102
 
@@ -62,28 +118,255 @@ function loadConfig() {
62
118
  return null;
63
119
  }
64
120
 
65
- function saveConfig(cfg) {
121
+ function saveConfig(c) {
66
122
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
67
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
123
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
124
+ }
125
+
126
+ // ---------- the dashboard ----------
127
+
128
+ function renderDashboard() {
129
+ if (!interactive) return;
130
+ const W = termWidth();
131
+ const rule = col("gray", "─".repeat(W));
132
+ const row = (label, value) => " " + col("gray", label.padEnd(11)) + value;
133
+ const key = (k, label) => " " + col("orange", k) + col("gray", " ") + label;
134
+
135
+ const out = [CLEAR + HIDE_CURSOR, ""];
136
+ out.push(" " + col("orange", "◆ ") + bold("PixelForge Uploader"));
137
+ out.push(" " + rule);
138
+ out.push("");
139
+ out.push(row("Status", col("green", "● running")));
140
+ out.push(row("Address", col("cyan", "http://localhost:" + PORT)));
141
+ out.push(row("Upload to", creatorPhrase(cfg)));
142
+ out.push(row("API key", col("gray", maskKey(cfg && cfg.apiKey))));
143
+ out.push(row("Uploads", uploadCount + " this session"));
144
+ out.push("");
145
+ out.push(" " + rule);
146
+ out.push(" " + bold("What now?") + col("gray", " (press a key)"));
147
+ out.push(key("k", "Change API key"));
148
+ out.push(key("c", "Change account / group"));
149
+ out.push(key("t", "Test my API key"));
150
+ out.push(key("o", "Open settings folder"));
151
+ out.push(key("r", "Redo setup"));
152
+ out.push(key("q", "Quit"));
153
+ out.push(" " + rule);
154
+ if (logLines.length) {
155
+ out.push(" " + bold("Recent"));
156
+ for (const l of logLines) out.push(" " + l);
157
+ } else {
158
+ out.push(" " + col("gray", "Waiting for icons from Studio. Leave this window open."));
159
+ }
160
+ out.push("");
161
+ process.stdout.write(out.join("\n") + "\n");
68
162
  }
69
163
 
70
- function ask(rl, q) {
71
- return new Promise((res) => rl.question(q, (a) => res(a.trim())));
164
+ // ---------- interactive prompts (shared by setup + the live menu) ----------
165
+
166
+ // Reads a line of input. Loops on bad input; Enter on its own returns null so
167
+ // callers can treat empty as "cancel / keep current".
168
+ async function askApiKey(ask) {
169
+ while (true) {
170
+ const v = await ask(" Paste your API key (or press Enter to cancel): ");
171
+ if (!v) return null;
172
+ if (v.length < 10) { console.log(" " + col("yellow", "That looks too short. Paste the whole key.")); continue; }
173
+ return v;
174
+ }
72
175
  }
73
176
 
177
+ async function askCreator(ask) {
178
+ const t = await ask(" Upload to (1) your account or (2) a group? [1/2]: ");
179
+ if (t === "") return null;
180
+ const creatorType = t === "2" ? "group" : "user";
181
+ while (true) {
182
+ const id = await ask(" Enter the " + (creatorType === "group" ? "group" : "account") + " id (numbers only): ");
183
+ if (!id) return null;
184
+ if (/^\d+$/.test(id)) return { creatorType, creatorId: id };
185
+ console.log(" " + col("yellow", "Please enter digits only (the numeric id)."));
186
+ }
187
+ }
188
+
189
+ // Runs `fn` with a line-reading prompt while the dashboard is paused. Restores
190
+ // the dashboard (and raw key handling) afterwards no matter what.
191
+ async function lineSession(fn) {
192
+ uiMode = "prompt";
193
+ if (interactive) { try { process.stdin.setRawMode(false); } catch (_) {} }
194
+ process.stdout.write(SHOW_CURSOR);
195
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
196
+ const ask = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
197
+ try {
198
+ return await fn(ask);
199
+ } finally {
200
+ rl.close();
201
+ if (interactive) {
202
+ try { process.stdin.setRawMode(true); } catch (_) {}
203
+ process.stdin.resume();
204
+ }
205
+ uiMode = "menu";
206
+ renderDashboard();
207
+ }
208
+ }
209
+
210
+ // ---------- first-run setup wizard ----------
211
+
74
212
  async function runSetup() {
213
+ if (interactive) process.stdout.write(CLEAR + SHOW_CURSOR);
214
+ const W = termWidth();
215
+ process.stdout.write("\n");
216
+ process.stdout.write(" " + col("orange", "◆ ") + bold("Welcome to the PixelForge Uploader") + "\n");
217
+ process.stdout.write(" " + col("gray", "─".repeat(W)) + "\n\n");
218
+ process.stdout.write(" This little app sends the icons you capture in Studio up to Roblox.\n");
219
+ process.stdout.write(" Let's set it up once. It takes about a minute.\n\n");
220
+ process.stdout.write(" " + bold("1.") + " Create an Open Cloud API key here:\n");
221
+ process.stdout.write(" " + col("cyan", CREDENTIALS_URL) + "\n");
222
+ process.stdout.write(" " + col("gray", 'Add the "Assets" API, tick asset:write, set IP to 0.0.0.0/0.') + "\n\n");
223
+
75
224
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
76
- console.log("\nPixelForge Uploader - first-time setup\n");
77
- console.log("Create an Open Cloud API key at https://create.roblox.com/dashboard/credentials");
78
- console.log("(add the \"asset\" API, check asset:write, set IP 0.0.0.0/0).\n");
79
- const apiKey = await ask(rl, "Paste your API key: ");
80
- let creatorType = (await ask(rl, "Upload to (1) your account or (2) a group? [1/2]: ")) === "2" ? "group" : "user";
81
- const creatorId = await ask(rl, `Enter your ${creatorType === "group" ? "group" : "user"} id (number): `);
225
+ const ask = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
226
+
227
+ let key;
228
+ while (true) {
229
+ key = await askApiKey(ask);
230
+ if (key) break;
231
+ process.stdout.write(" " + col("gray", "(An API key is needed to upload.)") + "\n");
232
+ }
233
+
234
+ process.stdout.write("\n " + bold("2.") + " Where should your icons be uploaded?\n");
235
+ let cr;
236
+ while (true) {
237
+ cr = await askCreator(ask);
238
+ if (cr) break;
239
+ process.stdout.write(" " + col("gray", "(Pick 1 or 2, then enter the numeric id.)") + "\n");
240
+ }
241
+
242
+ process.stdout.write("\n " + bold("Does this look right?") + "\n");
243
+ process.stdout.write(" API key " + col("gray", maskKey(key)) + "\n");
244
+ process.stdout.write(" Upload to " + (cr.creatorType === "group" ? "group " : "your account ") + cr.creatorId + "\n\n");
245
+ const ok = (await ask(" Save and start? [Y/n]: ")).toLowerCase();
82
246
  rl.close();
83
- const cfg = { apiKey, creatorType, creatorId };
84
- saveConfig(cfg);
85
- console.log(`\nSaved to ${CONFIG_FILE}\n`);
86
- return cfg;
247
+
248
+ if (ok === "n" || ok === "no") {
249
+ process.stdout.write("\n " + col("gray", "No problem, let's go again.") + "\n");
250
+ return runSetup();
251
+ }
252
+
253
+ const next = { apiKey: key, creatorType: cr.creatorType, creatorId: cr.creatorId };
254
+ saveConfig(next);
255
+ process.stdout.write("\n " + col("green", "✓ Saved.") + " " + col("gray", "(" + CONFIG_FILE + ")") + "\n");
256
+ return next;
257
+ }
258
+
259
+ // ---------- live menu actions ----------
260
+
261
+ function changeApiKey() {
262
+ return lineSession(async (ask) => {
263
+ process.stdout.write("\n " + bold("Change API key") + "\n");
264
+ process.stdout.write(" " + col("gray", "Create one at " + CREDENTIALS_URL + " (asset:write).") + "\n");
265
+ const key = await askApiKey(ask);
266
+ if (!key) { log(col("yellow", "Cancelled. API key unchanged.")); return; }
267
+ cfg = Object.assign({}, cfg, { apiKey: key });
268
+ saveConfig(cfg);
269
+ log(col("green", "✓ API key updated."));
270
+ });
271
+ }
272
+
273
+ function changeCreator() {
274
+ return lineSession(async (ask) => {
275
+ process.stdout.write("\n " + bold("Change where icons upload") + "\n");
276
+ const cr = await askCreator(ask);
277
+ if (!cr) { log(col("yellow", "Cancelled. Creator unchanged.")); return; }
278
+ cfg = Object.assign({}, cfg, cr);
279
+ saveConfig(cfg);
280
+ log(col("green", "✓ Now uploading to " + creatorPhrase(cfg) + "."));
281
+ });
282
+ }
283
+
284
+ function redoSetup() {
285
+ return lineSession(async (ask) => {
286
+ process.stdout.write("\n " + bold("Redo setup") + "\n");
287
+ const key = await askApiKey(ask);
288
+ if (!key) { log(col("yellow", "Cancelled. Nothing changed.")); return; }
289
+ const cr = await askCreator(ask);
290
+ if (!cr) { log(col("yellow", "Cancelled. Nothing changed.")); return; }
291
+ cfg = { apiKey: key, creatorType: cr.creatorType, creatorId: cr.creatorId };
292
+ saveConfig(cfg);
293
+ log(col("green", "✓ Setup saved. Uploading to " + creatorPhrase(cfg) + "."));
294
+ });
295
+ }
296
+
297
+ function openConfigFolder() {
298
+ try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } catch (_) {}
299
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
300
+ try {
301
+ require("child_process").spawn(opener, [CONFIG_DIR], { detached: true, stdio: "ignore" }).unref();
302
+ log(col("green", "✓ Opened ") + col("gray", CONFIG_DIR));
303
+ } catch (_) {
304
+ log(col("yellow", "Settings are in ") + col("gray", CONFIG_DIR));
305
+ }
306
+ }
307
+
308
+ // Non-destructive key check: Open Cloud authenticates before it validates the
309
+ // request body, so a junk request reveals whether the key is accepted without
310
+ // ever creating an asset. 401/403 => bad key; anything else => key recognised.
311
+ async function testKey() {
312
+ uiMode = "busy";
313
+ log(col("cyan", "→ Checking your API key with Roblox..."));
314
+ try {
315
+ const resp = await robloxRequest("POST", ASSETS_URL, {
316
+ "x-api-key": cfg.apiKey,
317
+ "Content-Type": "application/json",
318
+ "Content-Length": 2,
319
+ }, Buffer.from("{}"));
320
+ if (resp.status === 401 || resp.status === 403) {
321
+ log(col("red", "✗ Roblox rejected the key (HTTP " + resp.status + "). Check asset:write and the IP setting."));
322
+ } else {
323
+ log(col("green", "✓ Roblox accepted your key. You're ready to upload."));
324
+ }
325
+ } catch (e) {
326
+ log(col("red", "✗ Couldn't reach Roblox: " + (e && e.message ? e.message : e)));
327
+ } finally {
328
+ uiMode = "menu";
329
+ renderDashboard();
330
+ }
331
+ }
332
+
333
+ function quit() {
334
+ if (interactive) {
335
+ try { process.stdin.setRawMode(false); } catch (_) {}
336
+ process.stdout.write(SHOW_CURSOR + C.reset);
337
+ }
338
+ process.stdout.write("\n");
339
+ console.log(" " + col("orange", "PixelForge Uploader closed.") + " See you next time.\n");
340
+ if (server) { try { server.close(); } catch (_) {} }
341
+ process.exit(0);
342
+ }
343
+
344
+ function handleMenuKey(name) {
345
+ if (name === "q") return quit();
346
+ if (name === "k") return changeApiKey();
347
+ if (name === "c") return changeCreator();
348
+ if (name === "t") return testKey();
349
+ if (name === "o") return openConfigFolder();
350
+ if (name === "r") return redoSetup();
351
+ // anything else: ignore
352
+ }
353
+
354
+ function onKeypress(str, key) {
355
+ if (key && key.ctrl && key.name === "c") return quit();
356
+ if (uiMode !== "menu") return;
357
+ const name = (key && key.name) || str;
358
+ if (typeof name === "string") handleMenuKey(name.toLowerCase());
359
+ }
360
+
361
+ function attachMenu() {
362
+ if (!interactive) return;
363
+ readline.emitKeypressEvents(process.stdin);
364
+ try { process.stdin.setRawMode(true); } catch (_) {}
365
+ process.stdin.resume();
366
+ process.stdin.on("keypress", onKeypress);
367
+ uiMode = "menu";
368
+ // The first paint comes from the server's listen callback once the port is
369
+ // actually bound, so the dashboard never claims "running" prematurely.
87
370
  }
88
371
 
89
372
  // ---------- PNG encode (raw RGBA -> PNG, zlib) ----------
@@ -155,8 +438,8 @@ function robloxRequest(method, url, headers, bodyBuffer) {
155
438
 
156
439
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
157
440
 
158
- async function uploadImage(cfg, name, pngBuffer) {
159
- const creator = cfg.creatorType === "group" ? { groupId: Number(cfg.creatorId) } : { userId: Number(cfg.creatorId) };
441
+ async function uploadImage(c, name, pngBuffer) {
442
+ const creator = c.creatorType === "group" ? { groupId: Number(c.creatorId) } : { userId: Number(c.creatorId) };
160
443
  const meta = JSON.stringify({ assetType: "Image", displayName: name || "Icon", description: "Created with PixelForge", creationContext: { creator } });
161
444
  const boundary = "----PixelForgeUploader" + Date.now().toString(16);
162
445
  const CRLF = "\r\n";
@@ -166,7 +449,7 @@ async function uploadImage(cfg, name, pngBuffer) {
166
449
  const body = Buffer.concat([pre, pngBuffer, Buffer.from(`${CRLF}--${boundary}--${CRLF}`, "utf8")]);
167
450
 
168
451
  const post = await robloxRequest("POST", ASSETS_URL, {
169
- "x-api-key": cfg.apiKey,
452
+ "x-api-key": c.apiKey,
170
453
  "Content-Type": "multipart/form-data; boundary=" + boundary,
171
454
  "Content-Length": body.length,
172
455
  }, body);
@@ -190,7 +473,7 @@ async function uploadImage(cfg, name, pngBuffer) {
190
473
  if (!opId) throw new Error("Upload accepted but no operation id returned.");
191
474
  for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
192
475
  await sleep(POLL_INTERVAL_MS);
193
- const poll = await robloxRequest("GET", OPERATION_URL + opId[1], { "x-api-key": cfg.apiKey });
476
+ const poll = await robloxRequest("GET", OPERATION_URL + opId[1], { "x-api-key": c.apiKey });
194
477
  if (poll.status === 200 && poll.json) {
195
478
  id = readId(poll.json);
196
479
  if (id) return id;
@@ -216,58 +499,80 @@ function json(res, status, obj) {
216
499
  res.end(b);
217
500
  }
218
501
 
219
- function startServer(cfg) {
220
- const server = http.createServer(async (req, res) => {
502
+ function startServer() {
503
+ server = http.createServer(async (req, res) => {
221
504
  if (req.method === "GET" && req.url === "/ping") {
222
- return json(res, 200, { ok: true, app: "PixelForge Uploader", version: "2", configured: !!cfg });
505
+ return json(res, 200, { ok: true, app: "PixelForge Uploader", version: "3", configured: !!cfg });
223
506
  }
224
507
  if (req.method === "POST" && req.url.startsWith("/upload")) {
508
+ const name = req.headers["x-pf-name"] || "Icon";
225
509
  try {
226
510
  if (!cfg) return json(res, 400, { error: "Uploader not configured. Restart it and complete setup." });
227
511
  const width = Number(req.headers["x-pf-width"]);
228
512
  const height = Number(req.headers["x-pf-height"]);
229
- const name = req.headers["x-pf-name"] || "Icon";
230
513
  const rgba = await readBody(req);
231
514
  if (!width || !height) return json(res, 400, { error: "Missing image dimensions." });
232
515
  if (rgba.length !== width * height * 4) {
233
516
  return json(res, 400, { error: `Pixel data is ${rgba.length} bytes, expected ${width * height * 4}.` });
234
517
  }
235
- console.log(`[upload] ${name} (${width}x${height}) -> ${cfg.creatorType} ${cfg.creatorId}`);
518
+ log(col("cyan", "↑ ") + name + col("gray", " (" + width + "×" + height + ")"));
236
519
  const png = encodePng(rgba, width, height);
237
520
  const imageId = await uploadImage(cfg, name, png);
238
- console.log(`[upload] done -> Image id ${imageId}`);
521
+ uploadCount++;
522
+ log(col("green", "✓ " + name) + col("gray", " → Image ") + col("cyan", imageId));
239
523
  return json(res, 200, { imageId });
240
524
  } catch (err) {
241
- console.error("[upload] " + (err && err.message ? err.message : err));
242
- return json(res, 502, { error: err && err.message ? err.message : String(err) });
525
+ const msg = err && err.message ? err.message : String(err);
526
+ log(col("red", "✗ " + name + ": " + msg));
527
+ return json(res, 502, { error: msg });
243
528
  }
244
529
  }
245
530
  json(res, 404, { error: "Not found" });
246
531
  });
247
532
 
248
533
  server.listen(PORT, HOST, () => {
249
- console.log("=================================================");
250
- console.log(" PixelForge Uploader is running.");
251
- console.log(" Listening on http://localhost:" + PORT);
252
- console.log(" Uploading to " + cfg.creatorType + " " + cfg.creatorId + ".");
253
- console.log(" Leave this window open while you upload icons.");
254
- console.log("=================================================");
534
+ if (interactive) {
535
+ renderDashboard();
536
+ } else {
537
+ console.log("PixelForge Uploader running on http://localhost:" + PORT);
538
+ console.log("Uploading to " + creatorPhrase(cfg) + ". Leave this window open.");
539
+ }
255
540
  });
256
541
  server.on("error", (err) => {
542
+ if (interactive) {
543
+ try { process.stdin.setRawMode(false); } catch (_) {}
544
+ process.stdout.write(SHOW_CURSOR + C.reset);
545
+ }
257
546
  if (err && err.code === "EADDRINUSE") {
258
- console.error("Port " + PORT + " is already in use (another copy may be running).");
547
+ console.error("\n Port " + PORT + " is already in use. Another copy may already be running.");
259
548
  } else {
260
- console.error("Server error:", err);
549
+ console.error("\n Server error:", err);
261
550
  }
262
551
  process.exit(1);
263
552
  });
264
553
  }
265
554
 
555
+ // ---------- entry ----------
556
+
557
+ process.on("SIGINT", quit);
558
+ process.on("SIGTERM", quit);
559
+
266
560
  (async function main() {
267
- let cfg = loadConfig();
268
- if (process.argv[2] === "reconfigure" || !cfg) {
269
- if (!cfg) console.log("No saved configuration found.");
561
+ interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
562
+ cfg = loadConfig();
563
+
564
+ const wantReconfigure = process.argv[2] === "reconfigure";
565
+ if (wantReconfigure) cfg = null;
566
+
567
+ if (!cfg) {
568
+ if (!interactive) {
569
+ console.error("PixelForge Uploader isn't set up yet.");
570
+ console.error("Run it in a terminal to set it up, or pass PIXELFORGE_API_KEY and PIXELFORGE_CREATOR_ID.");
571
+ process.exit(1);
572
+ }
270
573
  cfg = await runSetup();
271
574
  }
272
- startServer(cfg);
575
+
576
+ startServer();
577
+ attachMenu();
273
578
  })();