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.
- package/README.md +21 -7
- package/package.json +1 -1
- 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
|
|
26
|
-
|
|
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
|
-
|
|
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).
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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(
|
|
121
|
+
function saveConfig(c) {
|
|
66
122
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
67
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
159
|
-
const creator =
|
|
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":
|
|
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":
|
|
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(
|
|
220
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
575
|
+
|
|
576
|
+
startServer();
|
|
577
|
+
attachMenu();
|
|
273
578
|
})();
|