@crashcontinuum/crashcart-stamp 1.0.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 ADDED
@@ -0,0 +1,91 @@
1
+ # @crashcontinuum/crashcart-stamp
2
+
3
+ License stamping tool for CrashCart game bundles.
4
+
5
+ Stamps your `.crashcart` files with a license from [Crash BASIC Arcade](https://arcade.crashbasic.com), embedding your subscription tier and commercial use rights directly into the game.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ # Use directly with npx (no install required)
11
+ npx @crashcontinuum/crashcart-stamp mygame.crashcart
12
+
13
+ # Or install globally
14
+ npm install -g @crashcontinuum/crashcart-stamp
15
+ crashcart-stamp mygame.crashcart
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ crashcart-stamp <crashcart-file> [options]
22
+ ```
23
+
24
+ ### Options
25
+
26
+ | Option | Description |
27
+ |--------|-------------|
28
+ | `-o, --output <file>` | Output file path (default: `<input>.stamped.crashcart`) |
29
+ | `--arcade-url <url>` | Arcade base URL (default: `https://arcade.crashbasic.com`) |
30
+ | `-h, --help` | Show help message |
31
+ | `-v, --version` | Show version number |
32
+
33
+ ### Examples
34
+
35
+ ```bash
36
+ # Stamp a crashcart (creates mygame.stamped.crashcart)
37
+ npx @crashcontinuum/crashcart-stamp mygame.crashcart
38
+
39
+ # Stamp with custom output filename
40
+ npx @crashcontinuum/crashcart-stamp mygame.crashcart -o mygame-licensed.crashcart
41
+
42
+ # Use a local development server
43
+ npx @crashcontinuum/crashcart-stamp mygame.crashcart --arcade-url http://localhost:3000
44
+ ```
45
+
46
+ ## How It Works
47
+
48
+ 1. **Reads** your CrashCart file and computes its SHA-256 checksum
49
+ 2. **Opens** your browser for authentication with Crash BASIC Arcade
50
+ 3. **Fetches** a signed license stamp from the Arcade API
51
+ 4. **Creates** a stamped copy of your CrashCart (original file unchanged)
52
+
53
+ The stamp is cryptographically signed and bound to your specific build via the checksum. Any modifications to the CrashCart will invalidate the stamp.
54
+
55
+ **Note:** The original file is never modified. A new `.stamped.crashcart` file is created.
56
+
57
+ ## License Tiers
58
+
59
+ | Tier | Commercial Use | Splash Screen | Subscription |
60
+ |------|----------------|---------------|--------------|
61
+ | **Free** | No | 3 seconds, required | Free |
62
+ | **Indie** | Yes | 2 seconds, skippable | Premium ($4.99/mo) |
63
+ | **Studio** | Yes | Removable | Multiplayer ($9.99/mo) |
64
+
65
+ Upgrade your license at: https://arcade.crashbasic.com/pricing
66
+
67
+ ## Stamped File Format
68
+
69
+ The stamp is appended to the end of the CrashCart file:
70
+
71
+ ```
72
+ [Original CrashCart Data]
73
+ [CRSTAMP\0 magic - 8 bytes]
74
+ [Stamp length - 4 bytes uint32 LE]
75
+ [JSON stamp data]
76
+ ```
77
+
78
+ The original CrashCart content is not modified, ensuring the stamp can be verified by computing:
79
+
80
+ ```
81
+ checksum = SHA256(file[0:metadataOffset + metadataLength + 64])
82
+ ```
83
+
84
+ ## Requirements
85
+
86
+ - Node.js 18.0.0 or later
87
+ - A Crash BASIC Arcade account (free or paid)
88
+
89
+ ## License
90
+
91
+ © 2026 Crash Continuum LLC. All rights reserved.
@@ -0,0 +1,739 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CrashCart License Stamping Tool
4
+ *
5
+ * Stamps a .crashcart file with a license from Crash BASIC Arcade.
6
+ * Opens a browser for authentication, then fetches and applies the license stamp.
7
+ *
8
+ * Usage:
9
+ * npx crashcart-stamp <crashcart-file> [options]
10
+ *
11
+ * Options:
12
+ * --arcade-url <url> Arcade base URL (default: https://arcade.crashbasic.com)
13
+ * --output <file> Output file path (default: <input>.stamped.crashcart)
14
+ * --help Show this help message
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import http from 'http';
19
+ import { URL } from 'url';
20
+ import crypto from 'crypto';
21
+ import { exec } from 'child_process';
22
+ import readline from 'readline';
23
+
24
+ // Configuration
25
+ const DEFAULT_ARCADE_URL = 'https://arcade.crashbasic.com';
26
+ const AUTH_CALLBACK_PORT = 19847; // Random high port for callback
27
+ const STAMP_MAGIC = Buffer.from('CRSTAMP\0'); // 8 bytes: license stamp marker
28
+
29
+ // CrashCart constants
30
+ const CRASHCART_MAGIC = Buffer.from('CRSHCART');
31
+ const HEADER_SIZE = 96;
32
+ const SIGNATURE_SIZE = 64;
33
+ const NONCE_SIZE = 12;
34
+ const TAG_SIZE = 16;
35
+ const HKDF_INFO = new TextEncoder().encode('crashcart-aes-key');
36
+
37
+ // Version
38
+ const VERSION = '1.0.0';
39
+
40
+ /**
41
+ * Parse command line arguments
42
+ */
43
+ function parseArgs() {
44
+ const args = process.argv.slice(2);
45
+ const options = {
46
+ inputFile: null,
47
+ outputFile: null,
48
+ arcadeUrl: DEFAULT_ARCADE_URL,
49
+ help: false,
50
+ version: false,
51
+ };
52
+
53
+ for (let i = 0; i < args.length; i++) {
54
+ const arg = args[i];
55
+
56
+ if (arg === '--help' || arg === '-h') {
57
+ options.help = true;
58
+ } else if (arg === '--version' || arg === '-v') {
59
+ options.version = true;
60
+ } else if (arg === '--arcade-url' && args[i + 1]) {
61
+ options.arcadeUrl = args[++i];
62
+ } else if ((arg === '--output' || arg === '-o') && args[i + 1]) {
63
+ options.outputFile = args[++i];
64
+ } else if (!arg.startsWith('-') && !options.inputFile) {
65
+ options.inputFile = arg;
66
+ }
67
+ }
68
+
69
+ return options;
70
+ }
71
+
72
+ /**
73
+ * Show help message
74
+ */
75
+ function showHelp() {
76
+ console.log(`
77
+ CrashCart License Stamping Tool v${VERSION}
78
+
79
+ Stamps a .crashcart file with a license from Crash BASIC Arcade.
80
+ This embeds your subscription tier and commercial use rights into the game.
81
+
82
+ USAGE:
83
+ npx crashcart-stamp <crashcart-file> [options]
84
+ crashcart-stamp <crashcart-file> [options]
85
+
86
+ OPTIONS:
87
+ --arcade-url <url> Arcade base URL (default: ${DEFAULT_ARCADE_URL})
88
+ -o, --output <file> Output file path (default: <input>.stamped.crashcart)
89
+ -h, --help Show this help message
90
+ -v, --version Show version number
91
+
92
+ NOTE: The original file is never modified. A stamped copy is always created.
93
+
94
+ EXAMPLES:
95
+ # Stamp a crashcart (creates mygame.stamped.crashcart)
96
+ npx crashcart-stamp mygame.crashcart
97
+
98
+ # Stamp with custom output
99
+ npx crashcart-stamp mygame.crashcart -o mygame-licensed.crashcart
100
+
101
+ # Use a different Arcade server (for development)
102
+ npx crashcart-stamp mygame.crashcart --arcade-url http://localhost:3000
103
+
104
+ LICENSE TIERS:
105
+ Free - Non-commercial use, 3-second splash screen
106
+ Indie - Commercial use allowed, 2-second skippable splash
107
+ Studio - Commercial use allowed, splash screen removable
108
+
109
+ Upgrade your license at: ${DEFAULT_ARCADE_URL}/pricing
110
+ `);
111
+ }
112
+
113
+ /**
114
+ * Derive AES-256 key from game secret using HKDF-SHA256
115
+ */
116
+ function deriveKey(gameSecret, salt) {
117
+ // HKDF extract
118
+ const prk = crypto.createHmac('sha256', salt).update(gameSecret).digest();
119
+
120
+ // HKDF expand (we only need 32 bytes for AES-256)
121
+ const info = Buffer.concat([HKDF_INFO, Buffer.from([0x01])]);
122
+ const okm = crypto.createHmac('sha256', prk).update(info).digest();
123
+
124
+ return okm.subarray(0, 32);
125
+ }
126
+
127
+ /**
128
+ * Decrypt data using AES-256-GCM
129
+ */
130
+ function decrypt(ciphertext, key, nonce) {
131
+ // Ciphertext includes the 16-byte auth tag at the end
132
+ const tag = ciphertext.subarray(ciphertext.length - TAG_SIZE);
133
+ const encrypted = ciphertext.subarray(0, ciphertext.length - TAG_SIZE);
134
+
135
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
136
+ decipher.setAuthTag(tag);
137
+
138
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
139
+ return decrypted;
140
+ }
141
+
142
+ /**
143
+ * Read and decrypt the CrashCart manifest
144
+ */
145
+ function readManifest(filePath, gameSecret = null) {
146
+ const fd = fs.openSync(filePath, 'r');
147
+ const headerBuf = Buffer.alloc(HEADER_SIZE);
148
+ fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
149
+
150
+ // Verify magic
151
+ const magic = headerBuf.subarray(0, 8);
152
+ if (!magic.equals(CRASHCART_MAGIC)) {
153
+ fs.closeSync(fd);
154
+ throw new Error('Invalid CrashCart file');
155
+ }
156
+
157
+ const metadataOffset = Number(headerBuf.readBigUInt64LE(16));
158
+ const metadataLength = Number(headerBuf.readBigUInt64LE(24));
159
+ const salt = headerBuf.subarray(32, 64);
160
+
161
+ // Read encrypted metadata
162
+ const metadataBuf = Buffer.alloc(metadataLength);
163
+ fs.readSync(fd, metadataBuf, 0, metadataLength, metadataOffset);
164
+ fs.closeSync(fd);
165
+
166
+ // Extract nonce (first 12 bytes) and ciphertext
167
+ const nonce = metadataBuf.subarray(0, NONCE_SIZE);
168
+ const ciphertext = metadataBuf.subarray(NONCE_SIZE);
169
+
170
+ // Use provided secret or default to all zeros
171
+ const secret = gameSecret || Buffer.alloc(32);
172
+
173
+ // Derive decryption key
174
+ const key = deriveKey(secret, salt);
175
+
176
+ // Decrypt
177
+ try {
178
+ const plaintext = decrypt(ciphertext, key, nonce);
179
+ return JSON.parse(plaintext.toString('utf8'));
180
+ } catch (err) {
181
+ if (!gameSecret) {
182
+ // If using default (all zeros) failed, the cart might be encrypted
183
+ throw new Error('ENCRYPTED');
184
+ }
185
+ throw new Error('Failed to decrypt manifest - wrong game secret?');
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Prompt for game secret
191
+ */
192
+ async function promptForSecret() {
193
+ const rl = readline.createInterface({
194
+ input: process.stdin,
195
+ output: process.stdout,
196
+ });
197
+
198
+ return new Promise((resolve) => {
199
+ rl.question('Enter game secret (hex or base64): ', (answer) => {
200
+ rl.close();
201
+ const trimmed = answer.trim();
202
+ if (!trimmed) {
203
+ resolve(null);
204
+ return;
205
+ }
206
+ // Try to parse as hex first, then base64
207
+ if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length === 64) {
208
+ resolve(Buffer.from(trimmed, 'hex'));
209
+ } else {
210
+ resolve(Buffer.from(trimmed, 'base64'));
211
+ }
212
+ });
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Read and parse CrashCart header to extract game info
218
+ */
219
+ function readCrashCartHeader(filePath) {
220
+ const fd = fs.openSync(filePath, 'r');
221
+ const headerBuf = Buffer.alloc(HEADER_SIZE);
222
+ fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
223
+ fs.closeSync(fd);
224
+
225
+ // Verify magic bytes
226
+ const magic = headerBuf.subarray(0, 8);
227
+ if (!magic.equals(CRASHCART_MAGIC)) {
228
+ throw new Error('Invalid CrashCart file: bad magic bytes');
229
+ }
230
+
231
+ // Parse header
232
+ const version = headerBuf.readUInt16LE(8);
233
+ const flags = headerBuf.readUInt16LE(10);
234
+ const fileCount = headerBuf.readUInt32LE(12);
235
+ const metadataOffset = headerBuf.readBigUInt64LE(16);
236
+ const metadataLength = headerBuf.readBigUInt64LE(24);
237
+ const salt = headerBuf.subarray(32, 64);
238
+ const authorKeyHash = headerBuf.subarray(64, 96);
239
+
240
+ return {
241
+ version,
242
+ flags,
243
+ fileCount,
244
+ metadataOffset: Number(metadataOffset),
245
+ metadataLength: Number(metadataLength),
246
+ salt: salt.toString('hex'),
247
+ authorKeyHash: authorKeyHash.toString('hex'),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Calculate the original CrashCart size from its header.
253
+ *
254
+ * The CrashCart format is:
255
+ * [Header: 96 bytes]
256
+ * [Data section: variable]
257
+ * [Metadata section: at metadataOffset, metadataLength bytes]
258
+ * [Signature: 64 bytes]
259
+ *
260
+ * So original size = metadataOffset + metadataLength + 64
261
+ *
262
+ * This is what we checksum, and what the runtime will checksum to verify.
263
+ */
264
+ function getOriginalCrashCartSize(filePath) {
265
+ const fd = fs.openSync(filePath, 'r');
266
+ const headerBuf = Buffer.alloc(HEADER_SIZE);
267
+ fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
268
+ fs.closeSync(fd);
269
+
270
+ // Verify magic
271
+ const magic = headerBuf.subarray(0, 8);
272
+ if (!magic.equals(CRASHCART_MAGIC)) {
273
+ throw new Error('Invalid CrashCart file');
274
+ }
275
+
276
+ const metadataOffset = headerBuf.readBigUInt64LE(16);
277
+ const metadataLength = headerBuf.readBigUInt64LE(24);
278
+
279
+ // Original size = metadata end + signature
280
+ return Number(metadataOffset) + Number(metadataLength) + SIGNATURE_SIZE;
281
+ }
282
+
283
+ /**
284
+ * Check if crashcart already has a stamp appended
285
+ */
286
+ function hasExistingStamp(filePath) {
287
+ const stats = fs.statSync(filePath);
288
+ const originalSize = getOriginalCrashCartSize(filePath);
289
+
290
+ // If file is larger than original CrashCart, there's extra data (stamp)
291
+ return stats.size > originalSize;
292
+ }
293
+
294
+ /**
295
+ * Compute SHA-256 checksum of the original CrashCart content.
296
+ *
297
+ * We hash exactly [0, originalSize) bytes - the CrashCart without any stamp.
298
+ * The runtime does the same calculation to verify the stamp.
299
+ *
300
+ * File structure after stamping:
301
+ * [Original CrashCart: header + data + metadata + signature] ← checksum this
302
+ * [CRSTAMP\0 magic + length + JSON stamp]
303
+ */
304
+ function computeChecksum(filePath) {
305
+ const originalSize = getOriginalCrashCartSize(filePath);
306
+
307
+ // Read only the original CrashCart bytes
308
+ const fd = fs.openSync(filePath, 'r');
309
+ const data = Buffer.alloc(originalSize);
310
+ fs.readSync(fd, data, 0, originalSize, 0);
311
+ fs.closeSync(fd);
312
+
313
+ const hash = crypto.createHash('sha256');
314
+ hash.update(data);
315
+ return hash.digest('hex');
316
+ }
317
+
318
+ /**
319
+ * Open browser for authentication
320
+ */
321
+ function openBrowser(url) {
322
+ const platform = process.platform;
323
+
324
+ let cmd;
325
+ if (platform === 'darwin') {
326
+ cmd = `open "${url}"`;
327
+ } else if (platform === 'win32') {
328
+ cmd = `start "" "${url}"`;
329
+ } else {
330
+ cmd = `xdg-open "${url}"`;
331
+ }
332
+
333
+ exec(cmd, (err) => {
334
+ if (err) {
335
+ console.log(`\nCould not open browser automatically.`);
336
+ console.log(`Please open this URL manually:\n ${url}\n`);
337
+ }
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Start local server and wait for auth callback
343
+ */
344
+ function waitForAuthCallback(arcadeUrl, state) {
345
+ return new Promise((resolve, reject) => {
346
+ let timeoutHandle = null;
347
+
348
+ const cleanup = () => {
349
+ if (timeoutHandle) {
350
+ clearTimeout(timeoutHandle);
351
+ timeoutHandle = null;
352
+ }
353
+ };
354
+
355
+ const server = http.createServer((req, res) => {
356
+ const url = new URL(req.url, `http://localhost:${AUTH_CALLBACK_PORT}`);
357
+
358
+ if (url.pathname === '/callback') {
359
+ const token = url.searchParams.get('token');
360
+ const returnedState = url.searchParams.get('state');
361
+ const error = url.searchParams.get('error');
362
+
363
+ // Send response to browser
364
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
365
+
366
+ if (error) {
367
+ res.end(`
368
+ <!DOCTYPE html>
369
+ <html>
370
+ <head><meta charset="utf-8"><title>Authentication Failed</title></head>
371
+ <body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
372
+ <div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
373
+ <div style="width: 64px; height: 64px; background: rgba(220, 38, 38, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
374
+ <span style="font-size: 32px; color: #dc2626;">✕</span>
375
+ </div>
376
+ <h1 style="color: #dc2626; margin: 0 0 12px; font-size: 24px;">Authentication Failed</h1>
377
+ <p style="margin: 0 0 16px; color: #1e293b;">${escapeHtml(error)}</p>
378
+ <p style="color: #64748b; margin: 0; font-size: 14px;">You can close this window.</p>
379
+ </div>
380
+ </body>
381
+ </html>
382
+ `);
383
+ cleanup();
384
+ server.close();
385
+ reject(new Error(error));
386
+ return;
387
+ }
388
+
389
+ if (returnedState !== state) {
390
+ res.end(`
391
+ <!DOCTYPE html>
392
+ <html>
393
+ <head><meta charset="utf-8"><title>Security Error</title></head>
394
+ <body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
395
+ <div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
396
+ <div style="width: 64px; height: 64px; background: rgba(220, 38, 38, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
397
+ <span style="font-size: 32px; color: #dc2626;">✕</span>
398
+ </div>
399
+ <h1 style="color: #dc2626; margin: 0 0 12px; font-size: 24px;">Security Error</h1>
400
+ <p style="margin: 0 0 16px; color: #1e293b;">State mismatch - possible CSRF attack. Please try again.</p>
401
+ <p style="color: #64748b; margin: 0; font-size: 14px;">You can close this window.</p>
402
+ </div>
403
+ </body>
404
+ </html>
405
+ `);
406
+ cleanup();
407
+ server.close();
408
+ reject(new Error('State mismatch'));
409
+ return;
410
+ }
411
+
412
+ res.end(`
413
+ <!DOCTYPE html>
414
+ <html>
415
+ <head><meta charset="utf-8"><title>Authenticated!</title></head>
416
+ <body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
417
+ <div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
418
+ <div style="width: 64px; height: 64px; background: rgba(20, 184, 166, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
419
+ <span style="font-size: 32px; color: #14b8a6;">✓</span>
420
+ </div>
421
+ <h1 style="color: #14b8a6; margin: 0 0 12px; font-size: 24px;">Authenticated!</h1>
422
+ <p style="margin: 0 0 16px; color: #1e293b;">You can close this window and return to your terminal.</p>
423
+ <p style="color: #64748b; margin: 0; font-size: 14px;">This window will close automatically...</p>
424
+ </div>
425
+ <script>setTimeout(() => window.close(), 2000);</script>
426
+ </body>
427
+ </html>
428
+ `);
429
+
430
+ cleanup();
431
+ server.close();
432
+ resolve(token);
433
+ } else {
434
+ res.writeHead(404);
435
+ res.end('Not found');
436
+ }
437
+ });
438
+
439
+ server.listen(AUTH_CALLBACK_PORT, '127.0.0.1', () => {
440
+ console.log(`Waiting for authentication...`);
441
+ });
442
+
443
+ server.on('error', (err) => {
444
+ cleanup();
445
+ if (err.code === 'EADDRINUSE') {
446
+ reject(new Error(`Port ${AUTH_CALLBACK_PORT} is already in use. Please close any other instances.`));
447
+ } else {
448
+ reject(err);
449
+ }
450
+ });
451
+
452
+ // Timeout after 5 minutes
453
+ timeoutHandle = setTimeout(() => {
454
+ server.close();
455
+ reject(new Error('Authentication timed out (5 minutes)'));
456
+ }, 5 * 60 * 1000);
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Escape HTML to prevent XSS in error messages
462
+ */
463
+ function escapeHtml(text) {
464
+ return text
465
+ .replace(/&/g, '&amp;')
466
+ .replace(/</g, '&lt;')
467
+ .replace(/>/g, '&gt;')
468
+ .replace(/"/g, '&quot;');
469
+ }
470
+
471
+ /**
472
+ * Authenticate with the Arcade
473
+ */
474
+ async function authenticate(arcadeUrl) {
475
+ // Generate state for CSRF protection
476
+ const state = crypto.randomBytes(16).toString('hex');
477
+
478
+ // Build auth URL
479
+ const callbackUrl = `http://127.0.0.1:${AUTH_CALLBACK_PORT}/callback`;
480
+ const authUrl = `${arcadeUrl}/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${state}`;
481
+
482
+ console.log(`\nOpening browser for authentication...`);
483
+ console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`);
484
+
485
+ openBrowser(authUrl);
486
+
487
+ // Wait for callback
488
+ const token = await waitForAuthCallback(arcadeUrl, state);
489
+ return token;
490
+ }
491
+
492
+ /**
493
+ * Fetch license stamp from Arcade API
494
+ */
495
+ async function fetchLicenseStamp(arcadeUrl, token, gameId, gameName, checksum) {
496
+ const stampResponse = await fetch(`${arcadeUrl}/api/license/stamp`, {
497
+ method: 'POST',
498
+ headers: {
499
+ 'Authorization': `Bearer ${token}`,
500
+ 'Content-Type': 'application/json',
501
+ },
502
+ body: JSON.stringify({ gameId, gameName, checksum }),
503
+ });
504
+
505
+ if (!stampResponse.ok) {
506
+ const error = await stampResponse.json().catch(() => ({}));
507
+
508
+ if (stampResponse.status === 401) {
509
+ throw new Error('Authentication failed. Please try again.');
510
+ }
511
+
512
+ throw new Error(error.error || `Failed to fetch license: ${stampResponse.status}`);
513
+ }
514
+
515
+ return stampResponse.json();
516
+ }
517
+
518
+ /**
519
+ * Apply stamp to crashcart file
520
+ */
521
+ function applyStamp(inputPath, outputPath, stamp) {
522
+ // Get the original CrashCart size (excludes any existing stamp)
523
+ const originalSize = getOriginalCrashCartSize(inputPath);
524
+
525
+ // Read only the original CrashCart content
526
+ const fd = fs.openSync(inputPath, 'r');
527
+ const data = Buffer.alloc(originalSize);
528
+ fs.readSync(fd, data, 0, originalSize, 0);
529
+ fs.closeSync(fd);
530
+
531
+ // Serialize the stamp
532
+ const stampJson = JSON.stringify(stamp);
533
+ const stampData = Buffer.from(stampJson, 'utf8');
534
+
535
+ // Build stamp section: [CRSTAMP\0][4-byte length][JSON]
536
+ const stampLength = Buffer.alloc(4);
537
+ stampLength.writeUInt32LE(stampData.length, 0);
538
+
539
+ const stampSection = Buffer.concat([
540
+ STAMP_MAGIC,
541
+ stampLength,
542
+ stampData,
543
+ ]);
544
+
545
+ // Append stamp to original CrashCart
546
+ const outputData = Buffer.concat([data, stampSection]);
547
+
548
+ // Write output
549
+ fs.writeFileSync(outputPath, outputData);
550
+ }
551
+
552
+ /**
553
+ * Display stamp info
554
+ */
555
+ function displayStampInfo(stamp) {
556
+ const tierColors = {
557
+ free: '\x1b[33m', // Yellow
558
+ indie: '\x1b[36m', // Cyan
559
+ studio: '\x1b[35m', // Magenta
560
+ };
561
+ const reset = '\x1b[0m';
562
+ const green = '\x1b[32m';
563
+ const red = '\x1b[31m';
564
+ const dim = '\x1b[2m';
565
+
566
+ console.log(`\n${'─'.repeat(60)}`);
567
+ console.log(`License Stamp Applied`);
568
+ console.log(`${'─'.repeat(60)}`);
569
+ console.log(` Game: ${stamp.gameName}`);
570
+ console.log(` Licensee: ${stamp.licensee}`);
571
+ console.log(` Tier: ${tierColors[stamp.tier] || ''}${stamp.tier.toUpperCase()}${reset}`);
572
+ console.log(` Commercial: ${stamp.features.commercialUse ? green + 'Yes' : red + 'No'}${reset}`);
573
+ console.log(` Splash: ${stamp.features.removeSplash ? green + 'Removable' : stamp.features.skippableSplash ? '2s (skippable)' : '3s (required)'}${reset}`);
574
+ console.log(` Build ID: ${stamp.buildId}`);
575
+ console.log(` Checksum: ${dim}${stamp.gameChecksum.substring(0, 16)}...${reset}`);
576
+ console.log(` Built: ${new Date(stamp.builtAt).toLocaleString()}`);
577
+ console.log(`${'─'.repeat(60)}\n`);
578
+ }
579
+
580
+ /**
581
+ * Display upgrade info for free users
582
+ */
583
+ function displayUpgradeInfo(arcadeUrl) {
584
+ console.log(`
585
+ ┌─────────────────────────────────────────────────────────────┐
586
+ │ UPGRADE YOUR LICENSE │
587
+ ├─────────────────────────────────────────────────────────────┤
588
+ │ │
589
+ │ Your game has been stamped with a FREE tier license. │
590
+ │ This means: │
591
+ │ • Non-commercial use only │
592
+ │ • 3-second splash screen required │
593
+ │ │
594
+ │ Upgrade to unlock: │
595
+ │ │
596
+ │ PREMIUM ($4.99/mo) │
597
+ │ ✓ Commercial use allowed │
598
+ │ ✓ 2-second skippable splash │
599
+ │ ✓ Sell your games │
600
+ │ │
601
+ │ MULTIPLAYER ($9.99/mo) │
602
+ │ ✓ Everything in Premium │
603
+ │ ✓ Removable splash screen │
604
+ │ ✓ Publish CrashNet multiplayer games │
605
+ │ │
606
+ │ Visit: ${arcadeUrl}/pricing
607
+ │ │
608
+ └─────────────────────────────────────────────────────────────┘
609
+ `);
610
+ }
611
+
612
+
613
+ /**
614
+ * Main entry point
615
+ */
616
+ async function main() {
617
+ const options = parseArgs();
618
+
619
+ if (options.version) {
620
+ console.log(`crashcart-stamp v${VERSION}`);
621
+ process.exit(0);
622
+ }
623
+
624
+ if (options.help) {
625
+ showHelp();
626
+ process.exit(0);
627
+ }
628
+
629
+ if (!options.inputFile) {
630
+ console.error('Error: No input file specified\n');
631
+ showHelp();
632
+ process.exit(1);
633
+ }
634
+
635
+ // Verify input file exists
636
+ if (!fs.existsSync(options.inputFile)) {
637
+ console.error(`Error: File not found: ${options.inputFile}`);
638
+ process.exit(1);
639
+ }
640
+
641
+ // Set default output file
642
+ if (!options.outputFile) {
643
+ const inputBase = options.inputFile.replace(/\.crashcart$/i, '');
644
+ options.outputFile = `${inputBase}.stamped.crashcart`;
645
+ }
646
+
647
+ // Resolve to absolute paths for comparison
648
+ const inputPath = fs.realpathSync(options.inputFile);
649
+ const outputPath = fs.existsSync(options.outputFile)
650
+ ? fs.realpathSync(options.outputFile)
651
+ : options.outputFile;
652
+
653
+ // Never overwrite the input file
654
+ if (inputPath === outputPath) {
655
+ console.error(`Error: Output file cannot be the same as input file.`);
656
+ console.error(`Use -o to specify a different output path.\n`);
657
+ process.exit(1);
658
+ }
659
+
660
+ console.log(`\n🎮 CrashCart License Stamping Tool v${VERSION}\n`);
661
+ console.log(`Input: ${options.inputFile}`);
662
+ console.log(`Output: ${options.outputFile} (original file unchanged)`);
663
+
664
+ try {
665
+ // Read and validate crashcart
666
+ console.log(`\nReading CrashCart header...`);
667
+ const header = readCrashCartHeader(options.inputFile);
668
+ console.log(` Version: ${header.version}`);
669
+ console.log(` Files: ${header.fileCount}`);
670
+ console.log(` Author Key Hash: ${header.authorKeyHash.substring(0, 16)}...`);
671
+
672
+ // Check for existing stamp
673
+ if (hasExistingStamp(options.inputFile)) {
674
+ console.log(`\n⚠️ This CrashCart already has a license stamp.`);
675
+ console.log(` The existing stamp will be replaced.\n`);
676
+ }
677
+
678
+ // Compute checksum of the CrashCart (binds stamp to this specific build)
679
+ console.log(`\nComputing file checksum...`);
680
+ const checksum = computeChecksum(options.inputFile);
681
+ console.log(` SHA-256: ${checksum.substring(0, 16)}...`);
682
+
683
+ // Read manifest to get game info
684
+ console.log(`\nReading manifest...`);
685
+ let manifest;
686
+ try {
687
+ manifest = readManifest(options.inputFile);
688
+ } catch (err) {
689
+ if (err.message === 'ENCRYPTED') {
690
+ console.log(` CrashCart is encrypted. Please provide the game secret.`);
691
+ const secret = await promptForSecret();
692
+ if (!secret) {
693
+ console.error('Error: Game secret is required for encrypted CrashCarts');
694
+ process.exit(1);
695
+ }
696
+ manifest = readManifest(options.inputFile, secret);
697
+ } else {
698
+ throw err;
699
+ }
700
+ }
701
+
702
+ const gameId = manifest.gameId;
703
+ const gameName = manifest.name || manifest.gameId;
704
+ console.log(` Game: ${gameName} (${gameId})`);
705
+
706
+ if (!gameId) {
707
+ console.error('Error: Could not read game ID from manifest');
708
+ process.exit(1);
709
+ }
710
+
711
+ // Authenticate
712
+ const token = await authenticate(options.arcadeUrl);
713
+ console.log(`\n✓ Authenticated successfully`);
714
+
715
+ // Fetch license stamp (includes checksum)
716
+ console.log(`\nFetching license stamp for "${gameName}"...`);
717
+ const stamp = await fetchLicenseStamp(options.arcadeUrl, token, gameId, gameName, checksum);
718
+
719
+ // Apply stamp
720
+ console.log(`Applying stamp to CrashCart...`);
721
+ applyStamp(options.inputFile, options.outputFile, stamp);
722
+
723
+ // Display results
724
+ displayStampInfo(stamp);
725
+
726
+ // Show upgrade info for free tier
727
+ if (stamp.tier === 'free') {
728
+ displayUpgradeInfo(options.arcadeUrl);
729
+ }
730
+
731
+ console.log(`✓ Successfully stamped: ${options.outputFile}\n`);
732
+
733
+ } catch (error) {
734
+ console.error(`\n❌ Error: ${error.message}\n`);
735
+ process.exit(1);
736
+ }
737
+ }
738
+
739
+ main();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@crashcontinuum/crashcart-stamp",
3
+ "version": "1.0.0",
4
+ "description": "License stamping tool for CrashCart game bundles",
5
+ "type": "module",
6
+ "bin": {
7
+ "crashcart-stamp": "./bin/crashcart-stamp.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "crashcart",
15
+ "crashbasic",
16
+ "crash-basic",
17
+ "game",
18
+ "license",
19
+ "stamp"
20
+ ],
21
+ "author": {
22
+ "name": "Crash Continuum LLC",
23
+ "url": "https://crashcontinuum.com"
24
+ },
25
+ "license": "UNLICENSED",
26
+ "homepage": "https://arcade.crashbasic.com",
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }