quilltap 3.3.0-dev → 3.3.0-dev.17
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/bin/quilltap.js +26 -10
- package/lib/theme-commands.js +1223 -0
- package/lib/theme-validation.js +386 -0
- package/package.json +4 -3
|
@@ -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 };
|