quilltap 3.3.0-dev → 3.3.0-dev.108

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.
@@ -0,0 +1,1223 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Theme CLI Commands
6
+ *
7
+ * Handles theme management from the command line:
8
+ * - list: Show installed themes
9
+ * - install: Install a .qtap-theme bundle
10
+ * - uninstall: Remove a bundle theme
11
+ * - validate: Check a .qtap-theme file
12
+ * - export: Export a theme as .qtap-theme
13
+ *
14
+ * @module quilltap/lib/theme-commands
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const fsp = require('fs/promises');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const crypto = require('crypto');
22
+ const { execSync } = require('child_process');
23
+ const { validateThemeBundle, validateManifest } = require('./theme-validation');
24
+
25
+ // ============================================================================
26
+ // COLOR HELPERS
27
+ // ============================================================================
28
+
29
+ const c = {
30
+ reset: '\x1b[0m',
31
+ bold: '\x1b[1m',
32
+ dim: '\x1b[2m',
33
+ green: '\x1b[32m',
34
+ red: '\x1b[31m',
35
+ yellow: '\x1b[33m',
36
+ cyan: '\x1b[36m',
37
+ blue: '\x1b[34m',
38
+ };
39
+
40
+ // ============================================================================
41
+ // PATH RESOLUTION
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Resolve the base data directory
46
+ */
47
+ function resolveBaseDir(overrideDir) {
48
+ if (overrideDir) {
49
+ const resolved = overrideDir.startsWith('~')
50
+ ? path.join(os.homedir(), overrideDir.slice(1))
51
+ : overrideDir;
52
+ return resolved;
53
+ }
54
+ if (process.env.QUILLTAP_DATA_DIR) {
55
+ return process.env.QUILLTAP_DATA_DIR;
56
+ }
57
+ const home = os.homedir();
58
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap');
59
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap');
60
+ return path.join(home, '.quilltap');
61
+ }
62
+
63
+ function getThemesDir(overrideDir) {
64
+ return path.join(resolveBaseDir(overrideDir), 'themes');
65
+ }
66
+
67
+ function getIndexPath(overrideDir) {
68
+ return path.join(getThemesDir(overrideDir), 'themes-index.json');
69
+ }
70
+
71
+ // ============================================================================
72
+ // INDEX MANAGEMENT
73
+ // ============================================================================
74
+
75
+ async function readIndex(overrideDir) {
76
+ const indexPath = getIndexPath(overrideDir);
77
+ try {
78
+ const data = await fsp.readFile(indexPath, 'utf-8');
79
+ return JSON.parse(data);
80
+ } catch {
81
+ return { version: 1, themes: [] };
82
+ }
83
+ }
84
+
85
+ async function writeIndex(index, overrideDir) {
86
+ const indexPath = getIndexPath(overrideDir);
87
+ await fsp.mkdir(path.dirname(indexPath), { recursive: true });
88
+ await fsp.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8');
89
+ }
90
+
91
+ // ============================================================================
92
+ // ZIP EXTRACTION (uses yauzl)
93
+ // ============================================================================
94
+
95
+ async function extractZipToDir(zipPath, destDir) {
96
+ const yauzl = require('yauzl');
97
+
98
+ const zipFile = await new Promise((resolve, reject) => {
99
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => {
100
+ if (err) reject(err);
101
+ else resolve(zf);
102
+ });
103
+ });
104
+
105
+ try {
106
+ await new Promise((resolve, reject) => {
107
+ zipFile.readEntry();
108
+ zipFile.on('entry', async (entry) => {
109
+ try {
110
+ const entryPath = path.join(destDir, entry.fileName);
111
+
112
+ // Security: prevent path traversal
113
+ if (!entryPath.startsWith(destDir + path.sep) && entryPath !== destDir) {
114
+ reject(new Error(`Path traversal detected: ${entry.fileName}`));
115
+ return;
116
+ }
117
+
118
+ if (/\/$/.test(entry.fileName)) {
119
+ await fsp.mkdir(entryPath, { recursive: true });
120
+ zipFile.readEntry();
121
+ } else {
122
+ await fsp.mkdir(path.dirname(entryPath), { recursive: true });
123
+ const data = await new Promise((res, rej) => {
124
+ zipFile.openReadStream(entry, (err, stream) => {
125
+ if (err) { rej(err); return; }
126
+ const chunks = [];
127
+ stream.on('data', (chunk) => chunks.push(chunk));
128
+ stream.on('end', () => res(Buffer.concat(chunks)));
129
+ stream.on('error', rej);
130
+ });
131
+ });
132
+ await fsp.writeFile(entryPath, data);
133
+ zipFile.readEntry();
134
+ }
135
+ } catch (err) {
136
+ reject(err);
137
+ }
138
+ });
139
+ zipFile.on('end', resolve);
140
+ zipFile.on('error', reject);
141
+ });
142
+ } finally {
143
+ zipFile.close();
144
+ }
145
+ }
146
+
147
+ // ============================================================================
148
+ // COMMANDS
149
+ // ============================================================================
150
+
151
+ /**
152
+ * List installed themes
153
+ */
154
+ async function listThemes(dataDir) {
155
+ const themesDir = getThemesDir(dataDir);
156
+ const index = await readIndex(dataDir);
157
+
158
+ console.log(`\n${c.bold}Installed Theme Bundles${c.reset}\n`);
159
+
160
+ if (index.themes.length === 0) {
161
+ console.log(` ${c.dim}No theme bundles installed.${c.reset}`);
162
+ console.log(` ${c.dim}Use "quilltap themes install <file>" to install a .qtap-theme bundle.${c.reset}`);
163
+ console.log('');
164
+ return;
165
+ }
166
+
167
+ for (const entry of index.themes) {
168
+ const themeDir = path.join(themesDir, entry.id);
169
+ const themeJsonPath = path.join(themeDir, 'theme.json');
170
+
171
+ let name = entry.id;
172
+ let description = '';
173
+ let version = entry.version;
174
+ let source = entry.source || 'file';
175
+
176
+ try {
177
+ const manifest = JSON.parse(await fsp.readFile(themeJsonPath, 'utf-8'));
178
+ name = manifest.name || entry.id;
179
+ description = manifest.description || '';
180
+ version = manifest.version || entry.version;
181
+ } catch {
182
+ // Use index data
183
+ }
184
+
185
+ const installedDate = entry.installedAt
186
+ ? new Date(entry.installedAt).toLocaleDateString()
187
+ : 'unknown';
188
+
189
+ console.log(` ${c.bold}${name}${c.reset} ${c.dim}(${entry.id})${c.reset}`);
190
+ console.log(` Version: ${version} Source: ${source} Installed: ${installedDate}`);
191
+ if (description) {
192
+ console.log(` ${c.dim}${description.substring(0, 80)}${description.length > 80 ? '...' : ''}${c.reset}`);
193
+ }
194
+ console.log('');
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Validate a .qtap-theme file
200
+ */
201
+ async function validateTheme(filePath) {
202
+ const resolvedPath = path.resolve(filePath);
203
+
204
+ console.log(`\n${c.bold}Validating:${c.reset} ${resolvedPath}\n`);
205
+
206
+ const result = await validateThemeBundle(resolvedPath);
207
+
208
+ if (result.valid) {
209
+ console.log(` ${c.green}Valid${c.reset} .qtap-theme bundle\n`);
210
+ if (result.manifest) {
211
+ console.log(` Name: ${result.manifest.name}`);
212
+ console.log(` ID: ${result.manifest.id}`);
213
+ console.log(` Version: ${result.manifest.version}`);
214
+ console.log(` Dark Mode: ${result.manifest.supportsDarkMode ? 'Yes' : 'No'}`);
215
+ if (result.manifest.author) console.log(` Author: ${result.manifest.author}`);
216
+ if (result.manifest.description) {
217
+ console.log(` Description: ${result.manifest.description.substring(0, 60)}${result.manifest.description.length > 60 ? '...' : ''}`);
218
+ }
219
+ }
220
+ console.log(` Files: ${result.fileCount}`);
221
+ console.log(` Size: ${(result.totalSize / 1024).toFixed(1)} KB`);
222
+ } else {
223
+ console.log(` ${c.red}Invalid${c.reset} .qtap-theme bundle\n`);
224
+ for (const error of result.errors) {
225
+ console.log(` ${c.red}Error:${c.reset} ${error}`);
226
+ }
227
+ }
228
+
229
+ if (result.warnings.length > 0) {
230
+ console.log('');
231
+ for (const warning of result.warnings) {
232
+ console.log(` ${c.yellow}Warning:${c.reset} ${warning}`);
233
+ }
234
+ }
235
+
236
+ console.log('');
237
+ return result.valid;
238
+ }
239
+
240
+ /**
241
+ * Install a .qtap-theme file
242
+ */
243
+ async function installTheme(source, dataDir) {
244
+ const resolvedSource = path.resolve(source);
245
+
246
+ console.log(`\n${c.bold}Installing theme from:${c.reset} ${resolvedSource}\n`);
247
+
248
+ // Validate first
249
+ const validation = await validateThemeBundle(resolvedSource);
250
+ if (!validation.valid || !validation.manifest) {
251
+ console.log(` ${c.red}Validation failed:${c.reset}`);
252
+ for (const error of validation.errors) {
253
+ console.log(` ${c.red}-${c.reset} ${error}`);
254
+ }
255
+ console.log('');
256
+ return false;
257
+ }
258
+
259
+ const manifest = validation.manifest;
260
+ const themeId = manifest.id;
261
+ const themesDir = getThemesDir(dataDir);
262
+ const installPath = path.join(themesDir, themeId);
263
+
264
+ // Remove existing installation
265
+ try {
266
+ await fsp.rm(installPath, { recursive: true, force: true });
267
+ } catch {
268
+ // Ignore
269
+ }
270
+
271
+ // Create directory and extract
272
+ await fsp.mkdir(installPath, { recursive: true });
273
+
274
+ try {
275
+ await extractZipToDir(resolvedSource, installPath);
276
+
277
+ // If theme.json is in a subdirectory, move contents up
278
+ const directThemeJson = path.join(installPath, 'theme.json');
279
+ try {
280
+ await fsp.access(directThemeJson);
281
+ } catch {
282
+ const entries = await fsp.readdir(installPath, { withFileTypes: true });
283
+ const subdirs = entries.filter(e => e.isDirectory());
284
+ for (const subdir of subdirs) {
285
+ const subThemeJson = path.join(installPath, subdir.name, 'theme.json');
286
+ try {
287
+ await fsp.access(subThemeJson);
288
+ const subPath = path.join(installPath, subdir.name);
289
+ const subEntries = await fsp.readdir(subPath);
290
+ for (const entry of subEntries) {
291
+ await fsp.rename(
292
+ path.join(subPath, entry),
293
+ path.join(installPath, entry)
294
+ );
295
+ }
296
+ await fsp.rmdir(subPath);
297
+ break;
298
+ } catch {
299
+ continue;
300
+ }
301
+ }
302
+ }
303
+
304
+ // Update index
305
+ const index = await readIndex(dataDir);
306
+ index.themes = index.themes.filter(t => t.id !== themeId);
307
+ index.themes.push({
308
+ id: themeId,
309
+ version: manifest.version,
310
+ installedAt: new Date().toISOString(),
311
+ source: 'file',
312
+ sourceUrl: null,
313
+ registrySource: null,
314
+ signatureVerified: false,
315
+ });
316
+ await writeIndex(index, dataDir);
317
+
318
+ console.log(` ${c.green}Installed successfully${c.reset}`);
319
+ console.log(` Theme: ${manifest.name} (${themeId}) v${manifest.version}`);
320
+ console.log(` Path: ${installPath}`);
321
+ console.log('');
322
+ return true;
323
+ } catch (err) {
324
+ // Clean up on failure
325
+ try { await fsp.rm(installPath, { recursive: true, force: true }); } catch { /* ignore */ }
326
+ console.log(` ${c.red}Installation failed:${c.reset} ${err.message}`);
327
+ console.log('');
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Uninstall a theme bundle
334
+ */
335
+ async function uninstallTheme(themeId, dataDir) {
336
+ const themesDir = getThemesDir(dataDir);
337
+ const installPath = path.join(themesDir, themeId);
338
+
339
+ console.log(`\n${c.bold}Uninstalling theme:${c.reset} ${themeId}\n`);
340
+
341
+ // Check if theme exists
342
+ try {
343
+ await fsp.access(installPath);
344
+ } catch {
345
+ console.log(` ${c.red}Theme "${themeId}" not found.${c.reset}`);
346
+ console.log('');
347
+ return false;
348
+ }
349
+
350
+ // Remove directory
351
+ await fsp.rm(installPath, { recursive: true, force: true });
352
+
353
+ // Update index
354
+ const index = await readIndex(dataDir);
355
+ index.themes = index.themes.filter(t => t.id !== themeId);
356
+ await writeIndex(index, dataDir);
357
+
358
+ console.log(` ${c.green}Uninstalled successfully.${c.reset}`);
359
+ console.log('');
360
+ return true;
361
+ }
362
+
363
+ /**
364
+ * Export a theme as .qtap-theme bundle
365
+ */
366
+ async function exportTheme(themeId, outputPath, dataDir) {
367
+ const themesDir = getThemesDir(dataDir);
368
+ const themePath = path.join(themesDir, themeId);
369
+
370
+ console.log(`\n${c.bold}Exporting theme:${c.reset} ${themeId}\n`);
371
+
372
+ // Check if theme exists
373
+ try {
374
+ await fsp.access(path.join(themePath, 'theme.json'));
375
+ } catch {
376
+ console.log(` ${c.red}Theme "${themeId}" not found in ${themesDir}.${c.reset}`);
377
+ console.log(` ${c.dim}Note: Only installed bundle themes can be exported from the CLI.${c.reset}`);
378
+ console.log(` ${c.dim}To export plugin themes, use the web UI export button.${c.reset}`);
379
+ console.log('');
380
+ return false;
381
+ }
382
+
383
+ // Default output path
384
+ const finalOutput = outputPath || `${themeId}.qtap-theme`;
385
+ const resolvedOutput = path.resolve(finalOutput);
386
+
387
+ // Create zip
388
+ try {
389
+ execSync(`cd "${themePath}" && zip -r "${resolvedOutput}" .`, { stdio: 'pipe' });
390
+ console.log(` ${c.green}Exported successfully${c.reset}`);
391
+ console.log(` Output: ${resolvedOutput}`);
392
+ console.log('');
393
+ return true;
394
+ } catch (err) {
395
+ console.log(` ${c.red}Export failed:${c.reset} ${err.message}`);
396
+ console.log('');
397
+ return false;
398
+ }
399
+ }
400
+
401
+ // ============================================================================
402
+ // SOURCES / REGISTRY HELPERS
403
+ // ============================================================================
404
+
405
+ function getSourcesPath(dataDir) {
406
+ return path.join(getThemesDir(dataDir), 'sources.json');
407
+ }
408
+
409
+ function getCacheDir(dataDir) {
410
+ return path.join(getThemesDir(dataDir), '.cache');
411
+ }
412
+
413
+ async function readSources(dataDir) {
414
+ const sourcesPath = getSourcesPath(dataDir);
415
+ try {
416
+ const data = await fsp.readFile(sourcesPath, 'utf-8');
417
+ return JSON.parse(data);
418
+ } catch {
419
+ return { version: 1, sources: [] };
420
+ }
421
+ }
422
+
423
+ async function writeSources(sources, dataDir) {
424
+ const sourcesPath = getSourcesPath(dataDir);
425
+ await fsp.mkdir(path.dirname(sourcesPath), { recursive: true });
426
+ await fsp.writeFile(sourcesPath, JSON.stringify(sources, null, 2), 'utf-8');
427
+ }
428
+
429
+ // ============================================================================
430
+ // REGISTRY COMMANDS
431
+ // ============================================================================
432
+
433
+ /**
434
+ * List configured registries
435
+ */
436
+ async function registryList(dataDir) {
437
+ const sources = await readSources(dataDir);
438
+
439
+ console.log(`\n${c.bold}Configured Theme Registries${c.reset}\n`);
440
+
441
+ if (sources.sources.length === 0) {
442
+ console.log(` ${c.dim}No registries configured.${c.reset}`);
443
+ console.log(` ${c.dim}Use "quilltap themes registry add <url>" to add one.${c.reset}`);
444
+ console.log('');
445
+ return;
446
+ }
447
+
448
+ for (const source of sources.sources) {
449
+ const status = source.enabled !== false ? `${c.green}enabled${c.reset}` : `${c.red}disabled${c.reset}`;
450
+ const keyDisplay = source.publicKey
451
+ ? `${source.publicKey.substring(0, 20)}...`
452
+ : `${c.dim}none${c.reset}`;
453
+ const lastFetched = source.lastFetched
454
+ ? new Date(source.lastFetched).toLocaleString()
455
+ : `${c.dim}never${c.reset}`;
456
+
457
+ console.log(` ${c.bold}${source.name}${c.reset} [${status}]`);
458
+ console.log(` URL: ${source.url}`);
459
+ console.log(` Public Key: ${keyDisplay}`);
460
+ console.log(` Last Fetched: ${lastFetched}`);
461
+ console.log('');
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Add a new registry source
467
+ */
468
+ async function registryAdd(url, options, dataDir) {
469
+ if (!url) {
470
+ console.error('Error: registry add requires a URL');
471
+ console.error('Usage: quilltap themes registry add <url> [--key <pubkey>] [--name <name>]');
472
+ process.exit(1);
473
+ }
474
+
475
+ const sources = await readSources(dataDir);
476
+
477
+ // Derive name from URL hostname if not provided
478
+ let name = options.name;
479
+ if (!name) {
480
+ try {
481
+ const parsed = new URL(url);
482
+ name = parsed.hostname.replace(/\./g, '-');
483
+ } catch {
484
+ name = 'registry-' + Date.now();
485
+ }
486
+ }
487
+
488
+ // Check for duplicate name
489
+ if (sources.sources.find(s => s.name === name)) {
490
+ console.error(`\n ${c.red}Error:${c.reset} A registry named "${name}" already exists.`);
491
+ console.error(` Use "quilltap themes registry remove ${name}" first, or choose a different --name.\n`);
492
+ process.exit(1);
493
+ }
494
+
495
+ const newSource = {
496
+ name,
497
+ url,
498
+ enabled: true,
499
+ publicKey: options.key || null,
500
+ lastFetched: null,
501
+ };
502
+
503
+ sources.sources.push(newSource);
504
+ await writeSources(sources, dataDir);
505
+
506
+ console.log(`\n ${c.green}Registry added successfully${c.reset}`);
507
+ console.log(` Name: ${name}`);
508
+ console.log(` URL: ${url}`);
509
+ if (options.key) {
510
+ console.log(` Key: ${options.key.substring(0, 20)}...`);
511
+ }
512
+ console.log('');
513
+ }
514
+
515
+ /**
516
+ * Remove a registry source by name
517
+ */
518
+ async function registryRemove(name, dataDir) {
519
+ if (!name) {
520
+ console.error('Error: registry remove requires a registry name');
521
+ console.error('Usage: quilltap themes registry remove <name>');
522
+ process.exit(1);
523
+ }
524
+
525
+ const sources = await readSources(dataDir);
526
+ const before = sources.sources.length;
527
+ sources.sources = sources.sources.filter(s => s.name !== name);
528
+
529
+ if (sources.sources.length === before) {
530
+ console.error(`\n ${c.red}Error:${c.reset} No registry named "${name}" found.\n`);
531
+ process.exit(1);
532
+ }
533
+
534
+ await writeSources(sources, dataDir);
535
+
536
+ // Also remove cached index if present
537
+ const cacheFile = path.join(getCacheDir(dataDir), `${name}.json`);
538
+ try {
539
+ await fsp.unlink(cacheFile);
540
+ } catch {
541
+ // Ignore if not cached
542
+ }
543
+
544
+ console.log(`\n ${c.green}Registry "${name}" removed.${c.reset}\n`);
545
+ }
546
+
547
+ /**
548
+ * Refresh all registry indexes
549
+ */
550
+ async function registryRefresh(dataDir) {
551
+ const sources = await readSources(dataDir);
552
+ const cacheDir = getCacheDir(dataDir);
553
+ await fsp.mkdir(cacheDir, { recursive: true });
554
+
555
+ const enabledSources = sources.sources.filter(s => s.enabled !== false);
556
+
557
+ if (enabledSources.length === 0) {
558
+ console.log(`\n ${c.dim}No enabled registries to refresh.${c.reset}\n`);
559
+ return;
560
+ }
561
+
562
+ console.log(`\n${c.bold}Refreshing registries...${c.reset}\n`);
563
+
564
+ for (const source of enabledSources) {
565
+ process.stdout.write(` ${source.name}: `);
566
+ try {
567
+ const response = await fetch(source.url);
568
+ if (!response.ok) {
569
+ console.log(`${c.red}HTTP ${response.status}${c.reset}`);
570
+ continue;
571
+ }
572
+
573
+ const text = await response.text();
574
+ let data;
575
+ try {
576
+ data = JSON.parse(text);
577
+ } catch {
578
+ console.log(`${c.red}invalid JSON${c.reset}`);
579
+ continue;
580
+ }
581
+
582
+ // Basic structure validation
583
+ if (!data.themes || !Array.isArray(data.themes)) {
584
+ console.log(`${c.red}invalid registry format (missing themes array)${c.reset}`);
585
+ continue;
586
+ }
587
+
588
+ // Write cache
589
+ const cacheFile = path.join(cacheDir, `${source.name}.json`);
590
+ await fsp.writeFile(cacheFile, JSON.stringify(data, null, 2), 'utf-8');
591
+
592
+ // Update lastFetched
593
+ source.lastFetched = new Date().toISOString();
594
+
595
+ console.log(`${c.green}OK${c.reset} (${data.themes.length} themes)`);
596
+ } catch (err) {
597
+ console.log(`${c.red}${err.message}${c.reset}`);
598
+ }
599
+ }
600
+
601
+ await writeSources(sources, dataDir);
602
+ console.log('');
603
+ }
604
+
605
+ /**
606
+ * Search across cached registry indexes
607
+ */
608
+ async function searchThemes(query, dataDir) {
609
+ if (!query) {
610
+ console.error('Error: search requires a query');
611
+ console.error('Usage: quilltap themes search <query>');
612
+ process.exit(1);
613
+ }
614
+
615
+ const cacheDir = getCacheDir(dataDir);
616
+ const queryLower = query.toLowerCase();
617
+ const results = [];
618
+
619
+ let cacheFiles;
620
+ try {
621
+ cacheFiles = await fsp.readdir(cacheDir);
622
+ } catch {
623
+ console.log(`\n ${c.dim}No cached registry data. Run "quilltap themes registry refresh" first.${c.reset}\n`);
624
+ return;
625
+ }
626
+
627
+ for (const file of cacheFiles) {
628
+ if (!file.endsWith('.json')) continue;
629
+ const registryName = path.basename(file, '.json');
630
+ try {
631
+ const data = JSON.parse(await fsp.readFile(path.join(cacheDir, file), 'utf-8'));
632
+ if (!data.themes || !Array.isArray(data.themes)) continue;
633
+
634
+ for (const theme of data.themes) {
635
+ const searchable = [
636
+ theme.id || '',
637
+ theme.name || '',
638
+ theme.description || '',
639
+ ...(theme.tags || []),
640
+ ].join(' ').toLowerCase();
641
+
642
+ if (searchable.includes(queryLower)) {
643
+ results.push({ ...theme, _registry: registryName });
644
+ }
645
+ }
646
+ } catch {
647
+ // Skip malformed cache files
648
+ }
649
+ }
650
+
651
+ console.log(`\n${c.bold}Search Results for "${query}"${c.reset}\n`);
652
+
653
+ if (results.length === 0) {
654
+ console.log(` ${c.dim}No themes found matching "${query}".${c.reset}\n`);
655
+ return;
656
+ }
657
+
658
+ for (const theme of results) {
659
+ console.log(` ${c.bold}${theme.name || theme.id}${c.reset} ${c.dim}(${theme.id})${c.reset}`);
660
+ console.log(` Version: ${theme.version || 'unknown'} Author: ${theme.author || 'unknown'} Registry: ${theme._registry}`);
661
+ if (theme.description) {
662
+ console.log(` ${c.dim}${theme.description.substring(0, 80)}${theme.description.length > 80 ? '...' : ''}${c.reset}`);
663
+ }
664
+ console.log('');
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Update themes from registry
670
+ */
671
+ async function updateThemes(themeId, dataDir) {
672
+ const cacheDir = getCacheDir(dataDir);
673
+ const index = await readIndex(dataDir);
674
+
675
+ // Build registry lookup from cached indexes
676
+ const registryThemes = {};
677
+ let cacheFiles;
678
+ try {
679
+ cacheFiles = await fsp.readdir(cacheDir);
680
+ } catch {
681
+ console.log(`\n ${c.dim}No cached registry data. Run "quilltap themes registry refresh" first.${c.reset}\n`);
682
+ return;
683
+ }
684
+
685
+ for (const file of cacheFiles) {
686
+ if (!file.endsWith('.json')) continue;
687
+ const registryName = path.basename(file, '.json');
688
+ try {
689
+ const data = JSON.parse(await fsp.readFile(path.join(cacheDir, file), 'utf-8'));
690
+ if (!data.themes || !Array.isArray(data.themes)) continue;
691
+ for (const theme of data.themes) {
692
+ if (theme.id) {
693
+ registryThemes[theme.id] = { ...theme, _registry: registryName };
694
+ }
695
+ }
696
+ } catch {
697
+ // Skip malformed cache files
698
+ }
699
+ }
700
+
701
+ // Find updates
702
+ const updates = [];
703
+ const themesToCheck = themeId
704
+ ? index.themes.filter(t => t.id === themeId)
705
+ : index.themes;
706
+
707
+ if (themeId && themesToCheck.length === 0) {
708
+ console.log(`\n ${c.red}Theme "${themeId}" is not installed.${c.reset}\n`);
709
+ return;
710
+ }
711
+
712
+ for (const installed of themesToCheck) {
713
+ const available = registryThemes[installed.id];
714
+ if (available && available.version && available.version !== installed.version) {
715
+ updates.push({ installed, available });
716
+ }
717
+ }
718
+
719
+ if (updates.length === 0) {
720
+ console.log(`\n ${c.green}All themes are up to date.${c.reset}\n`);
721
+ return;
722
+ }
723
+
724
+ console.log(`\n${c.bold}Available Updates${c.reset}\n`);
725
+
726
+ for (const { installed, available } of updates) {
727
+ console.log(` ${c.bold}${available.name || installed.id}${c.reset}`);
728
+ console.log(` ${installed.version} -> ${c.green}${available.version}${c.reset}`);
729
+
730
+ if (!available.downloadUrl) {
731
+ console.log(` ${c.yellow}No download URL available, skipping.${c.reset}`);
732
+ console.log('');
733
+ continue;
734
+ }
735
+
736
+ process.stdout.write(` Downloading... `);
737
+ try {
738
+ const response = await fetch(available.downloadUrl);
739
+ if (!response.ok) {
740
+ console.log(`${c.red}HTTP ${response.status}${c.reset}`);
741
+ continue;
742
+ }
743
+
744
+ const buffer = Buffer.from(await response.arrayBuffer());
745
+
746
+ // Verify SHA-256 hash if provided
747
+ if (available.sha256) {
748
+ const hash = crypto.createHash('sha256').update(buffer).digest('hex');
749
+ if (hash !== available.sha256) {
750
+ console.log(`${c.red}hash mismatch${c.reset}`);
751
+ console.log(` Expected: ${available.sha256}`);
752
+ console.log(` Got: ${hash}`);
753
+ continue;
754
+ }
755
+ }
756
+
757
+ // Write to temp file and install
758
+ const tmpFile = path.join(os.tmpdir(), `quilltap-update-${installed.id}-${Date.now()}.qtap-theme`);
759
+ await fsp.writeFile(tmpFile, buffer);
760
+
761
+ console.log(`${c.green}OK${c.reset}`);
762
+ process.stdout.write(` Installing... `);
763
+
764
+ // Use existing install logic
765
+ const validation = await validateThemeBundle(tmpFile);
766
+ if (!validation.valid || !validation.manifest) {
767
+ console.log(`${c.red}validation failed${c.reset}`);
768
+ await fsp.unlink(tmpFile).catch(() => {});
769
+ continue;
770
+ }
771
+
772
+ const manifest = validation.manifest;
773
+ const themesDir = getThemesDir(dataDir);
774
+ const installPath = path.join(themesDir, installed.id);
775
+
776
+ await fsp.rm(installPath, { recursive: true, force: true });
777
+ await fsp.mkdir(installPath, { recursive: true });
778
+ await extractZipToDir(tmpFile, installPath);
779
+
780
+ // Handle subdirectory layout
781
+ const directThemeJson = path.join(installPath, 'theme.json');
782
+ try {
783
+ await fsp.access(directThemeJson);
784
+ } catch {
785
+ const entries = await fsp.readdir(installPath, { withFileTypes: true });
786
+ const subdirs = entries.filter(e => e.isDirectory());
787
+ for (const subdir of subdirs) {
788
+ const subThemeJson = path.join(installPath, subdir.name, 'theme.json');
789
+ try {
790
+ await fsp.access(subThemeJson);
791
+ const subPath = path.join(installPath, subdir.name);
792
+ const subEntries = await fsp.readdir(subPath);
793
+ for (const entry of subEntries) {
794
+ await fsp.rename(
795
+ path.join(subPath, entry),
796
+ path.join(installPath, entry)
797
+ );
798
+ }
799
+ await fsp.rmdir(subPath);
800
+ break;
801
+ } catch {
802
+ continue;
803
+ }
804
+ }
805
+ }
806
+
807
+ // Update index entry
808
+ const currentIndex = await readIndex(dataDir);
809
+ currentIndex.themes = currentIndex.themes.filter(t => t.id !== installed.id);
810
+ currentIndex.themes.push({
811
+ id: installed.id,
812
+ version: manifest.version,
813
+ installedAt: new Date().toISOString(),
814
+ source: 'registry',
815
+ sourceUrl: available.downloadUrl,
816
+ registrySource: available._registry,
817
+ signatureVerified: false,
818
+ });
819
+ await writeIndex(currentIndex, dataDir);
820
+
821
+ console.log(`${c.green}OK${c.reset} (v${manifest.version})`);
822
+
823
+ // Clean up temp file
824
+ await fsp.unlink(tmpFile).catch(() => {});
825
+ } catch (err) {
826
+ console.log(`${c.red}${err.message}${c.reset}`);
827
+ }
828
+ console.log('');
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Generate Ed25519 keypair for registry signing
834
+ */
835
+ async function registryKeygen(outputDir) {
836
+ console.log(`\n${c.bold}Generating Ed25519 Keypair${c.reset}\n`);
837
+
838
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
839
+ publicKeyEncoding: { type: 'spki', format: 'der' },
840
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
841
+ });
842
+
843
+ const pubKeyStr = `ed25519:${publicKey.toString('base64')}`;
844
+ const privKeyStr = `ed25519:${privateKey.toString('base64')}`;
845
+
846
+ if (outputDir) {
847
+ const resolvedDir = path.resolve(outputDir);
848
+ await fsp.mkdir(resolvedDir, { recursive: true });
849
+
850
+ const pubPath = path.join(resolvedDir, 'registry-key.pub');
851
+ const privPath = path.join(resolvedDir, 'registry-key.priv');
852
+
853
+ await fsp.writeFile(pubPath, pubKeyStr + '\n', 'utf-8');
854
+ await fsp.writeFile(privPath, privKeyStr + '\n', { mode: 0o600, encoding: 'utf-8' });
855
+
856
+ console.log(` ${c.green}Keys written:${c.reset}`);
857
+ console.log(` Public: ${pubPath}`);
858
+ console.log(` Private: ${privPath}`);
859
+ console.log(`\n ${c.yellow}Keep the private key secret!${c.reset}`);
860
+ } else {
861
+ console.log(` ${c.bold}Public Key:${c.reset}`);
862
+ console.log(` ${pubKeyStr}`);
863
+ console.log('');
864
+ console.log(` ${c.bold}Private Key:${c.reset}`);
865
+ console.log(` ${privKeyStr}`);
866
+ console.log(`\n ${c.yellow}Keep the private key secret!${c.reset}`);
867
+ }
868
+ console.log('');
869
+ }
870
+
871
+ /**
872
+ * Sign a registry directory with an Ed25519 private key
873
+ */
874
+ async function registrySign(dir, privateKeyStr) {
875
+ if (!dir) {
876
+ console.error('Error: registry sign requires a directory path');
877
+ console.error('Usage: quilltap themes registry sign <dir> --key <private-key>');
878
+ process.exit(1);
879
+ }
880
+ if (!privateKeyStr) {
881
+ console.error('Error: registry sign requires --key <private-key>');
882
+ console.error('Usage: quilltap themes registry sign <dir> --key <private-key>');
883
+ process.exit(1);
884
+ }
885
+
886
+ const resolvedDir = path.resolve(dir);
887
+ console.log(`\n${c.bold}Signing registry directory:${c.reset} ${resolvedDir}\n`);
888
+
889
+ // Parse the private key
890
+ let keyBuffer;
891
+ if (privateKeyStr.startsWith('ed25519:')) {
892
+ keyBuffer = Buffer.from(privateKeyStr.slice(8), 'base64');
893
+ } else {
894
+ // Try reading as a file path
895
+ try {
896
+ const fileContent = (await fsp.readFile(privateKeyStr, 'utf-8')).trim();
897
+ if (fileContent.startsWith('ed25519:')) {
898
+ keyBuffer = Buffer.from(fileContent.slice(8), 'base64');
899
+ } else {
900
+ console.error(` ${c.red}Error:${c.reset} Invalid key format. Expected "ed25519:<base64>".`);
901
+ process.exit(1);
902
+ }
903
+ } catch {
904
+ console.error(` ${c.red}Error:${c.reset} Invalid key format and not a readable file path.`);
905
+ process.exit(1);
906
+ }
907
+ }
908
+
909
+ const privateKey = crypto.createPrivateKey({
910
+ key: keyBuffer,
911
+ format: 'der',
912
+ type: 'pkcs8',
913
+ });
914
+
915
+ // Collect all files recursively (excluding signature.json)
916
+ async function collectFiles(dirPath, basePath) {
917
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
918
+ const files = [];
919
+ for (const entry of entries) {
920
+ const fullPath = path.join(dirPath, entry.name);
921
+ const relPath = path.relative(basePath, fullPath);
922
+ if (entry.isDirectory()) {
923
+ files.push(...await collectFiles(fullPath, basePath));
924
+ } else if (entry.name !== 'signature.json') {
925
+ files.push(relPath);
926
+ }
927
+ }
928
+ return files.sort();
929
+ }
930
+
931
+ const files = await collectFiles(resolvedDir, resolvedDir);
932
+ const fileHashes = {};
933
+
934
+ for (const relPath of files) {
935
+ const fullPath = path.join(resolvedDir, relPath);
936
+ const content = await fsp.readFile(fullPath);
937
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
938
+ fileHashes[relPath] = hash;
939
+ }
940
+
941
+ // Create canonical hash data string
942
+ const hashData = Object.entries(fileHashes)
943
+ .map(([file, hash]) => `${hash} ${file}`)
944
+ .join('\n');
945
+
946
+ // Sign the hash data
947
+ const signature = crypto.sign(null, Buffer.from(hashData, 'utf-8'), privateKey);
948
+ const signatureB64 = signature.toString('base64');
949
+
950
+ // Write signature.json
951
+ const signatureDoc = {
952
+ version: 1,
953
+ algorithm: 'ed25519',
954
+ signature: signatureB64,
955
+ files: fileHashes,
956
+ signedAt: new Date().toISOString(),
957
+ };
958
+
959
+ const sigPath = path.join(resolvedDir, 'signature.json');
960
+ await fsp.writeFile(sigPath, JSON.stringify(signatureDoc, null, 2), 'utf-8');
961
+
962
+ console.log(` ${c.green}Signed ${files.length} files${c.reset}`);
963
+ console.log(` Signature written to: ${sigPath}`);
964
+ console.log('');
965
+ }
966
+
967
+ /**
968
+ * Handle registry subcommands
969
+ */
970
+ async function registryCommand(args, dataDir) {
971
+ const subcommand = args[0] || '';
972
+ const subArgs = args.slice(1);
973
+
974
+ // Parse options from subArgs
975
+ const positional = [];
976
+ const options = {};
977
+ let i = 0;
978
+ while (i < subArgs.length) {
979
+ switch (subArgs[i]) {
980
+ case '--key': case '-k': options.key = subArgs[++i]; break;
981
+ case '--name': case '-n': options.name = subArgs[++i]; break;
982
+ case '--output': case '-o': options.output = subArgs[++i]; break;
983
+ default:
984
+ if (subArgs[i] && !subArgs[i].startsWith('-')) {
985
+ positional.push(subArgs[i]);
986
+ } else if (subArgs[i]) {
987
+ console.error(`Unknown option: ${subArgs[i]}`);
988
+ process.exit(1);
989
+ }
990
+ break;
991
+ }
992
+ i++;
993
+ }
994
+
995
+ switch (subcommand) {
996
+ case 'list':
997
+ await registryList(dataDir);
998
+ break;
999
+
1000
+ case 'add':
1001
+ await registryAdd(positional[0], options, dataDir);
1002
+ break;
1003
+
1004
+ case 'remove':
1005
+ await registryRemove(positional[0], dataDir);
1006
+ break;
1007
+
1008
+ case 'refresh':
1009
+ await registryRefresh(dataDir);
1010
+ break;
1011
+
1012
+ case 'keygen':
1013
+ await registryKeygen(options.output);
1014
+ break;
1015
+
1016
+ case 'sign':
1017
+ await registrySign(positional[0], options.key);
1018
+ break;
1019
+
1020
+ default:
1021
+ if (subcommand) {
1022
+ console.error(`Unknown registry command: ${subcommand}`);
1023
+ }
1024
+ console.log(`
1025
+ ${c.bold}Registry Commands${c.reset}
1026
+
1027
+ Usage: quilltap themes registry <command> [options]
1028
+
1029
+ ${c.bold}Commands:${c.reset}
1030
+ list List configured registries
1031
+ add <url> [--key <pubkey>] [--name <name>]
1032
+ Add a new registry source
1033
+ remove <name> Remove a registry source
1034
+ refresh Refresh all registry indexes
1035
+
1036
+ ${c.bold}For Registry Operators:${c.reset}
1037
+ keygen [--output <dir>] Generate Ed25519 keypair
1038
+ sign <dir> --key <private-key> Sign a registry directory
1039
+ `);
1040
+ if (!subcommand) process.exit(0);
1041
+ process.exit(1);
1042
+ }
1043
+ }
1044
+
1045
+ // ============================================================================
1046
+ // HELP
1047
+ // ============================================================================
1048
+
1049
+ function printHelp() {
1050
+ console.log(`
1051
+ ${c.bold}Quilltap Theme Manager${c.reset}
1052
+
1053
+ Usage: quilltap themes <command> [options]
1054
+
1055
+ ${c.bold}Commands:${c.reset}
1056
+ list List installed theme bundles
1057
+ install <file> Install a .qtap-theme bundle
1058
+ uninstall <id> Uninstall a theme bundle
1059
+ validate <file> Validate a .qtap-theme file
1060
+ export <id> [--output <path>] Export a theme as .qtap-theme
1061
+ create <name> Create a new theme (delegates to create-quilltap-theme)
1062
+ search <query> Search across registries for themes
1063
+ update [id] Update one or all themes from registry
1064
+
1065
+ ${c.bold}Registry Commands:${c.reset}
1066
+ registry list List configured registries
1067
+ registry add <url> Add a new registry source
1068
+ [--key <pubkey>] Ed25519 public key for verification
1069
+ [--name <name>] Display name (default: derived from URL)
1070
+ registry remove <name> Remove a registry source
1071
+ registry refresh Refresh all registry indexes
1072
+
1073
+ ${c.bold}Registry Operator Commands:${c.reset}
1074
+ registry keygen [--output <dir>] Generate Ed25519 keypair
1075
+ registry sign <dir> --key <private-key> Sign a registry directory
1076
+
1077
+ ${c.bold}Options:${c.reset}
1078
+ --data-dir <path> Override data directory
1079
+ -h, --help Show this help
1080
+
1081
+ ${c.bold}Examples:${c.reset}
1082
+ quilltap themes list
1083
+ quilltap themes validate my-theme.qtap-theme
1084
+ quilltap themes install my-theme.qtap-theme
1085
+ quilltap themes uninstall my-theme
1086
+ quilltap themes export my-theme --output ./my-theme.qtap-theme
1087
+ quilltap themes create sunset
1088
+ quilltap themes registry add https://themes.example.com/index.json --name example
1089
+ quilltap themes registry refresh
1090
+ quilltap themes search steampunk
1091
+ quilltap themes update my-theme
1092
+ `);
1093
+ }
1094
+
1095
+ // ============================================================================
1096
+ // MAIN ENTRY POINT
1097
+ // ============================================================================
1098
+
1099
+ async function themesCommand(args) {
1100
+ let dataDirOverride = '';
1101
+ let showHelp = false;
1102
+ let command = '';
1103
+ const positional = [];
1104
+ let outputPath = '';
1105
+ // Track where the command was found so we can pass remaining args to sub-dispatchers
1106
+ let commandIndex = -1;
1107
+
1108
+ let i = 0;
1109
+ while (i < args.length) {
1110
+ switch (args[i]) {
1111
+ case '--data-dir': case '-d': dataDirOverride = args[++i]; break;
1112
+ case '--output': case '-o': outputPath = args[++i]; break;
1113
+ case '--help': case '-h': showHelp = true; break;
1114
+ default:
1115
+ if (args[i].startsWith('-')) {
1116
+ // For registry/search/update, pass unknown flags through
1117
+ if (command === 'registry' || command === 'search' || command === 'update') {
1118
+ positional.push(args[i]);
1119
+ } else {
1120
+ console.error(`Unknown option: ${args[i]}`);
1121
+ process.exit(1);
1122
+ }
1123
+ } else if (!command) {
1124
+ command = args[i];
1125
+ commandIndex = i;
1126
+ } else {
1127
+ positional.push(args[i]);
1128
+ }
1129
+ break;
1130
+ }
1131
+ i++;
1132
+ }
1133
+
1134
+ if (showHelp || !command) {
1135
+ printHelp();
1136
+ process.exit(0);
1137
+ }
1138
+
1139
+ switch (command) {
1140
+ case 'list':
1141
+ await listThemes(dataDirOverride);
1142
+ break;
1143
+
1144
+ case 'validate': {
1145
+ if (positional.length === 0) {
1146
+ console.error('Error: validate requires a file path');
1147
+ console.error('Usage: quilltap themes validate <file.qtap-theme>');
1148
+ process.exit(1);
1149
+ }
1150
+ const isValid = await validateTheme(positional[0]);
1151
+ process.exit(isValid ? 0 : 1);
1152
+ break;
1153
+ }
1154
+
1155
+ case 'install': {
1156
+ if (positional.length === 0) {
1157
+ console.error('Error: install requires a file path');
1158
+ console.error('Usage: quilltap themes install <file.qtap-theme>');
1159
+ process.exit(1);
1160
+ }
1161
+ const installed = await installTheme(positional[0], dataDirOverride);
1162
+ process.exit(installed ? 0 : 1);
1163
+ break;
1164
+ }
1165
+
1166
+ case 'uninstall': {
1167
+ if (positional.length === 0) {
1168
+ console.error('Error: uninstall requires a theme ID');
1169
+ console.error('Usage: quilltap themes uninstall <theme-id>');
1170
+ process.exit(1);
1171
+ }
1172
+ const uninstalled = await uninstallTheme(positional[0], dataDirOverride);
1173
+ process.exit(uninstalled ? 0 : 1);
1174
+ break;
1175
+ }
1176
+
1177
+ case 'export': {
1178
+ if (positional.length === 0) {
1179
+ console.error('Error: export requires a theme ID');
1180
+ console.error('Usage: quilltap themes export <theme-id> [--output <path>]');
1181
+ process.exit(1);
1182
+ }
1183
+ const exported = await exportTheme(positional[0], outputPath, dataDirOverride);
1184
+ process.exit(exported ? 0 : 1);
1185
+ break;
1186
+ }
1187
+
1188
+ case 'create': {
1189
+ // Delegate to create-quilltap-theme
1190
+ const createArgs = positional.join(' ');
1191
+ console.log(`\nDelegating to create-quilltap-theme...\n`);
1192
+ try {
1193
+ execSync(`npx create-quilltap-theme ${createArgs}`, {
1194
+ stdio: 'inherit',
1195
+ cwd: process.cwd(),
1196
+ });
1197
+ } catch {
1198
+ process.exit(1);
1199
+ }
1200
+ break;
1201
+ }
1202
+
1203
+ case 'registry':
1204
+ // Pass all args after 'registry' (including flags) to the sub-dispatcher
1205
+ await registryCommand(positional, dataDirOverride);
1206
+ break;
1207
+
1208
+ case 'search':
1209
+ await searchThemes(positional[0], dataDirOverride);
1210
+ break;
1211
+
1212
+ case 'update':
1213
+ await updateThemes(positional[0] || null, dataDirOverride);
1214
+ break;
1215
+
1216
+ default:
1217
+ console.error(`Unknown command: ${command}`);
1218
+ printHelp();
1219
+ process.exit(1);
1220
+ }
1221
+ }
1222
+
1223
+ module.exports = { themesCommand };