pal-explorer-cli 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +63 -2
  3. package/extensions/@palexplorer/analytics/extension.json +20 -1
  4. package/extensions/@palexplorer/analytics/index.js +19 -9
  5. package/extensions/@palexplorer/audit/extension.json +14 -0
  6. package/extensions/@palexplorer/auth-email/extension.json +15 -0
  7. package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
  8. package/extensions/@palexplorer/chat/extension.json +14 -0
  9. package/extensions/@palexplorer/discovery/extension.json +17 -0
  10. package/extensions/@palexplorer/discovery/index.js +1 -1
  11. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  12. package/extensions/@palexplorer/groups/extension.json +15 -0
  13. package/extensions/@palexplorer/share-links/extension.json +15 -0
  14. package/extensions/@palexplorer/sync/extension.json +16 -0
  15. package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
  16. package/lib/capabilities.js +24 -24
  17. package/lib/commands/analytics.js +175 -175
  18. package/lib/commands/api-keys.js +131 -131
  19. package/lib/commands/audit.js +235 -235
  20. package/lib/commands/auth.js +137 -137
  21. package/lib/commands/backup.js +76 -76
  22. package/lib/commands/billing.js +148 -148
  23. package/lib/commands/chat.js +217 -217
  24. package/lib/commands/cloud-backup.js +231 -231
  25. package/lib/commands/comment.js +99 -99
  26. package/lib/commands/completion.js +203 -203
  27. package/lib/commands/compliance.js +218 -218
  28. package/lib/commands/config.js +136 -136
  29. package/lib/commands/connect.js +44 -44
  30. package/lib/commands/dept.js +294 -294
  31. package/lib/commands/device.js +146 -146
  32. package/lib/commands/download.js +240 -226
  33. package/lib/commands/explorer.js +178 -178
  34. package/lib/commands/extension.js +1060 -970
  35. package/lib/commands/favorite.js +90 -90
  36. package/lib/commands/federation.js +270 -270
  37. package/lib/commands/file.js +533 -533
  38. package/lib/commands/group.js +271 -271
  39. package/lib/commands/gui-share.js +29 -29
  40. package/lib/commands/init.js +61 -61
  41. package/lib/commands/invite.js +59 -59
  42. package/lib/commands/list.js +58 -58
  43. package/lib/commands/log.js +116 -116
  44. package/lib/commands/nearby.js +108 -108
  45. package/lib/commands/network.js +251 -251
  46. package/lib/commands/notify.js +198 -198
  47. package/lib/commands/org.js +273 -273
  48. package/lib/commands/pal.js +403 -180
  49. package/lib/commands/permissions.js +216 -216
  50. package/lib/commands/pin.js +97 -97
  51. package/lib/commands/protocol.js +357 -357
  52. package/lib/commands/rbac.js +147 -147
  53. package/lib/commands/recover.js +36 -36
  54. package/lib/commands/register.js +171 -171
  55. package/lib/commands/relay.js +131 -131
  56. package/lib/commands/remote.js +368 -368
  57. package/lib/commands/revoke.js +50 -50
  58. package/lib/commands/scanner.js +280 -280
  59. package/lib/commands/schedule.js +344 -344
  60. package/lib/commands/scim.js +203 -203
  61. package/lib/commands/search.js +181 -181
  62. package/lib/commands/serve.js +438 -438
  63. package/lib/commands/server.js +350 -350
  64. package/lib/commands/share-link.js +199 -199
  65. package/lib/commands/share.js +336 -323
  66. package/lib/commands/sso.js +200 -200
  67. package/lib/commands/status.js +145 -145
  68. package/lib/commands/stream.js +562 -562
  69. package/lib/commands/su.js +187 -187
  70. package/lib/commands/sync.js +979 -979
  71. package/lib/commands/transfers.js +152 -152
  72. package/lib/commands/uninstall.js +188 -188
  73. package/lib/commands/update.js +204 -204
  74. package/lib/commands/user.js +276 -276
  75. package/lib/commands/vfs.js +84 -84
  76. package/lib/commands/web-login.js +79 -79
  77. package/lib/commands/web.js +52 -52
  78. package/lib/commands/webhook.js +180 -180
  79. package/lib/commands/whoami.js +59 -59
  80. package/lib/commands/workspace.js +121 -121
  81. package/lib/core/billing.js +16 -5
  82. package/lib/core/dhtDiscovery.js +9 -2
  83. package/lib/core/discoveryClient.js +13 -7
  84. package/lib/core/extensions.js +142 -1
  85. package/lib/core/identity.js +33 -2
  86. package/lib/core/imageProcessor.js +109 -0
  87. package/lib/core/imageTorrent.js +167 -0
  88. package/lib/core/permissions.js +1 -1
  89. package/lib/core/pro.js +11 -4
  90. package/lib/core/serverList.js +4 -1
  91. package/lib/core/shares.js +12 -1
  92. package/lib/core/signalingServer.js +14 -2
  93. package/lib/core/su.js +1 -1
  94. package/lib/core/users.js +1 -1
  95. package/lib/protocol/messages.js +12 -3
  96. package/lib/utils/explorer.js +1 -1
  97. package/lib/utils/help.js +357 -357
  98. package/lib/utils/torrent.js +1 -0
  99. package/package.json +4 -3
@@ -1,970 +1,1060 @@
1
- import chalk from 'chalk';
2
- import crypto from 'crypto';
3
- import { execSync, spawnSync } from 'child_process';
4
- import fs from 'fs';
5
- import os from 'os';
6
- import path from 'path';
7
- import readline from 'readline';
8
- import config from '../utils/config.js';
9
-
10
- async function getApiKey() {
11
- if (process.env.PAL_API_KEY) return process.env.PAL_API_KEY;
12
- try {
13
- const keytar = (await import('keytar')).default;
14
- const key = await keytar.getPassword('palexplorer', 'apiKey');
15
- if (key) return key;
16
- } catch {}
17
- return config.get('apiKey');
18
- }
19
-
20
- function confirm(question) {
21
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
22
- return new Promise(resolve => {
23
- rl.question(question, answer => {
24
- rl.close();
25
- resolve(answer.toLowerCase().startsWith('y'));
26
- });
27
- });
28
- }
29
-
30
- export default function extensionCommand(program) {
31
- const cmdName = process.argv[2] === 'extension' ? 'extension' : 'ext';
32
- const cmd = program
33
- .command(cmdName)
34
- .description('manage extensions (install, remove, enable, disable)')
35
- .addHelpText('after', `
36
- Examples:
37
- $ pe ext list List installed extensions
38
- $ pe ext install ./my-extension Install from local path
39
- $ pe ext install https://github.com/user/pe-ext-slack.git
40
- $ pe ext remove slack-notify Uninstall an extension
41
- $ pe ext enable slack-notify Enable a disabled extension
42
- $ pe ext disable slack-notify Disable without uninstalling
43
- $ pe ext info slack-notify Show extension details
44
- $ pe ext config slack-notify webhookUrl https://hooks.slack.com/...
45
- $ pe ext create my-extension Scaffold a new extension
46
- $ pe ext audit Security audit all extensions
47
- `)
48
- .action(async () => {
49
- await listExtensions();
50
- });
51
-
52
- cmd
53
- .command('list')
54
- .description('list installed extensions')
55
- .action(async () => { await listExtensions(); });
56
-
57
- cmd
58
- .command('install <source>')
59
- .description('install extension from path or git URL')
60
- .option('-y, --yes', 'Skip confirmation prompt')
61
- .action(async (source, opts) => {
62
- try {
63
- const { installExtension, HIGH_RISK_PERMISSIONS } = await import('../core/extensions.js');
64
- console.log(chalk.cyan(`Installing extension from ${source}...`));
65
- const result = await installExtension(source);
66
-
67
- // Show security info
68
- const riskColor = result.risk === 'high' ? chalk.red : result.risk === 'medium' ? chalk.yellow : chalk.green;
69
- console.log('');
70
- console.log(` Name: ${chalk.white(result.name)} v${result.version}`);
71
- console.log(` Risk: ${riskColor(result.risk.toUpperCase())}`);
72
- console.log(` Signed: ${result.signature?.verified ? chalk.green('verified ✔') : chalk.yellow('not signed')}`);
73
-
74
- if (result.permissions?.length) {
75
- console.log(` Permissions:`);
76
- for (const p of result.permissions) {
77
- const isHigh = HIGH_RISK_PERMISSIONS.has(p) || p.startsWith('exec:');
78
- console.log(` ${isHigh ? chalk.red('⚠') : chalk.gray('·')} ${isHigh ? chalk.red(p) : p}`);
79
- }
80
- }
81
-
82
- // Require confirmation for unsigned or high-risk extensions
83
- if (!opts.yes && (result.risk !== 'low' || !result.signature?.verified)) {
84
- console.log('');
85
- if (!result.signature?.verified) {
86
- console.log(chalk.yellow(' ⚠ This extension is NOT signed. It has not been reviewed by the Palexplorer team.'));
87
- }
88
- if (result.risk === 'high') {
89
- console.log(chalk.red(' ⚠ HIGH RISK: This extension requests dangerous permissions.'));
90
- }
91
- const ok = await confirm(chalk.white('\n Proceed with installation? (y/N) '));
92
- if (!ok) {
93
- // Remove the already-copied files
94
- const { removeExtension } = await import('../core/extensions.js');
95
- try { removeExtension(result.name); } catch {}
96
- console.log(chalk.gray(' Installation cancelled.'));
97
- return;
98
- }
99
- }
100
-
101
- console.log(chalk.green(`\n✔ Installed "${result.name}" v${result.version}`));
102
- } catch (err) {
103
- console.error(chalk.red(`Install failed: ${err.message}`));
104
- process.exitCode = 1;
105
- }
106
- });
107
-
108
- cmd
109
- .command('remove <name>')
110
- .description('uninstall an extension')
111
- .action(async (extName) => {
112
- try {
113
- const { removeExtension } = await import('../core/extensions.js');
114
- removeExtension(extName);
115
- console.log(chalk.green(`✔ Removed "${extName}"`));
116
- } catch (err) {
117
- console.error(chalk.red(`Remove failed: ${err.message}`));
118
- process.exitCode = 1;
119
- }
120
- });
121
-
122
- cmd
123
- .command('enable <name>')
124
- .description('enable a disabled extension')
125
- .action(async (extName) => {
126
- const { enableExtension } = await import('../core/extensions.js');
127
- enableExtension(extName);
128
- console.log(chalk.green(`✔ Enabled "${extName}"`));
129
- });
130
-
131
- cmd
132
- .command('disable <name>')
133
- .description('disable an extension without uninstalling')
134
- .action(async (extName) => {
135
- const { disableExtension } = await import('../core/extensions.js');
136
- disableExtension(extName);
137
- console.log(chalk.green(`✔ Disabled "${extName}"`));
138
- });
139
-
140
- cmd
141
- .command('info <name>')
142
- .description('show extension details')
143
- .action(async (extName) => {
144
- const { getInstalledExtensions, getExtensionConfig } = await import('../core/extensions.js');
145
- const extensions = getInstalledExtensions();
146
- const ext = extensions.find(e => e.name === extName || `@palexplorer/${e.name}` === extName);
147
- if (!ext) {
148
- console.log(chalk.red(`Extension "${extName}" not found.`));
149
- process.exitCode = 1;
150
- return;
151
- }
152
- console.log('');
153
- console.log(chalk.cyan(ext.bundled ? `@palexplorer/${ext.name}` : ext.name));
154
- console.log(` Version: ${chalk.white(ext.version)}`);
155
- console.log(` Description: ${chalk.gray(ext.description || 'N/A')}`);
156
- console.log(` Author: ${chalk.gray(ext.author || 'N/A')}`);
157
- console.log(` Status: ${ext.enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
158
- console.log(` Bundled: ${ext.bundled ? chalk.blue('yes') : 'no'}`);
159
- const tier = ext.tier || (ext.pro ? 'pro' : 'free');
160
- const tierColor = tier === 'enterprise' ? chalk.magenta : tier === 'pro' ? chalk.yellow : chalk.green;
161
- console.log(` Tier: ${tierColor(tier.toUpperCase())}${ext.price ? ` (${ext.price})` : ''}`);
162
- if (ext.hooks?.length) {
163
- console.log(` Hooks: ${chalk.gray(ext.hooks.join(', '))}`);
164
- }
165
- if (ext.permissions?.length) {
166
- console.log(` Permissions:`);
167
- for (const p of ext.permissions) {
168
- console.log(` - ${p}`);
169
- }
170
- }
171
- const cfg = getExtensionConfig(extName);
172
- if (Object.keys(cfg).length) {
173
- console.log(` Config:`);
174
- for (const [k, v] of Object.entries(cfg)) {
175
- console.log(` ${k}: ${chalk.white(JSON.stringify(v))}`);
176
- }
177
- }
178
- });
179
-
180
- cmd
181
- .command('config <name> <key> [value]')
182
- .description('get or set extension config value')
183
- .action(async (extName, key, value) => {
184
- const { getExtensionConfig, setExtensionConfig } = await import('../core/extensions.js');
185
- if (value === undefined) {
186
- const cfg = getExtensionConfig(extName);
187
- console.log(cfg[key] !== undefined ? JSON.stringify(cfg[key]) : chalk.gray('(not set)'));
188
- } else {
189
- // Try to parse as JSON, fallback to string
190
- let parsed = value;
191
- try { parsed = JSON.parse(value); } catch {}
192
- setExtensionConfig(extName, key, parsed);
193
- console.log(chalk.green(`✔ ${extName}.${key} = ${JSON.stringify(parsed)}`));
194
- }
195
- });
196
-
197
- cmd
198
- .command('audit')
199
- .description('security audit all installed extensions')
200
- .action(async () => {
201
- const {
202
- getInstalledExtensions, verifySignature, computeIntegrityHash,
203
- getSecurityRisk, hasBlockedImports, scanDangerousPatterns, BLOCKED_MODULES,
204
- } = await import('../core/extensions.js');
205
- const config = (await import('../utils/config.js')).default;
206
- const extensions = getInstalledExtensions();
207
-
208
- if (extensions.length === 0) {
209
- console.log(chalk.gray('No extensions installed.'));
210
- return;
211
- }
212
-
213
- console.log('');
214
- console.log(chalk.cyan.bold('Extension Security Audit'));
215
- console.log(chalk.gray('─'.repeat(60)));
216
- let issues = 0;
217
-
218
- for (const ext of extensions) {
219
- const fullName = ext.bundled ? `@palexplorer/${ext.name}` : ext.name;
220
- const sig = verifySignature(ext.path, ext);
221
- const risk = getSecurityRisk(ext);
222
- const mainPath = path.join(ext.path, ext.main || 'index.js');
223
- const blocked = hasBlockedImports(mainPath);
224
- const storedHash = config.get(`ext_integrity.${ext.name}`);
225
- const currentHash = computeIntegrityHash(ext.path, ext);
226
- const tampered = storedHash && storedHash !== currentHash;
227
-
228
- const riskColor = risk === 'high' ? chalk.red : risk === 'medium' ? chalk.yellow : chalk.green;
229
- console.log('');
230
- console.log(` ${ext.bundled ? chalk.blue(fullName) : chalk.white(fullName)} v${ext.version}`);
231
- console.log(` Status: ${ext.enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
232
- console.log(` Risk: ${riskColor(risk)}`);
233
- console.log(` Signed: ${sig.verified ? chalk.green('verified ✔') : sig.signed ? chalk.red('INVALID ✖') : chalk.yellow('unsigned')}`);
234
- console.log(` Integrity: ${tampered ? chalk.red('TAMPERED ✖') : storedHash ? chalk.green('OK ✔') : chalk.gray('no baseline')}`);
235
-
236
- const dangerous = !ext.bundled ? scanDangerousPatterns(mainPath) : [];
237
-
238
- if (blocked) {
239
- console.log(` ${chalk.red(`⚠ BLOCKED: imports '${blocked}'`)}`);
240
- issues++;
241
- }
242
- if (dangerous.length > 0) {
243
- console.log(` ${chalk.yellow(`⚠ Dangerous patterns: ${dangerous.join(', ')}`)}`);
244
- issues++;
245
- }
246
- if (tampered) {
247
- console.log(` ${chalk.red('⚠ Files modified since installation!')}`);
248
- issues++;
249
- }
250
- if (!ext.bundled && !sig.verified) {
251
- console.log(` ${chalk.yellow('⚠ Not signed — not reviewed by Palexplorer team')}`);
252
- issues++;
253
- }
254
- if (risk === 'high') {
255
- const highPerms = (ext.permissions || []).filter(p =>
256
- ['fs:write', 'fs:delete', 'net:http', 'net:ws', 'messages:send', 'shares:write'].includes(p) || p.startsWith('exec:')
257
- );
258
- console.log(` ${chalk.red(`⚠ High-risk permissions: ${highPerms.join(', ')}`)}`);
259
- issues++;
260
- }
261
- }
262
-
263
- console.log('');
264
- console.log(chalk.gray('─'.repeat(60)));
265
- if (issues === 0) {
266
- console.log(chalk.green(`✔ No security issues found across ${extensions.length} extension(s).`));
267
- } else {
268
- console.log(chalk.yellow(`⚠ ${issues} issue(s) found across ${extensions.length} extension(s).`));
269
- }
270
- console.log('');
271
- });
272
-
273
- cmd
274
- .command('sign <extPath>')
275
- .description('sign an extension with your identity key')
276
- .action(async (extPath) => {
277
- try {
278
- const { readManifest } = await import('../core/extensions.js');
279
- const { getIdentity } = await import('../core/identity.js');
280
- const sodium = (await import('sodium-native')).default;
281
-
282
- const resolvedPath = path.resolve(extPath);
283
- const manifest = readManifest(resolvedPath);
284
- if (!manifest) {
285
- console.error(chalk.red('No valid extension.json found at path'));
286
- process.exitCode = 1;
287
- return;
288
- }
289
-
290
- const identity = await getIdentity();
291
- if (!identity || !identity.privateKey) {
292
- console.error(chalk.red('No identity found. Run "pe init" first.'));
293
- process.exitCode = 1;
294
- return;
295
- }
296
-
297
- manifest.signerPublicKey = identity.publicKey;
298
- const manifestJson = JSON.stringify(manifest, null, 2) + '\n';
299
- fs.writeFileSync(path.join(resolvedPath, 'extension.json'), manifestJson);
300
-
301
- const manifestData = Buffer.from(manifestJson);
302
- const mainData = fs.readFileSync(path.join(resolvedPath, manifest.main || 'index.js'));
303
- const content = Buffer.concat([manifestData, mainData]);
304
-
305
- const secretKey = Buffer.from(identity.privateKey, 'hex');
306
- const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
307
- sodium.crypto_sign_detached(sig, content, secretKey);
308
-
309
- fs.writeFileSync(path.join(resolvedPath, 'extension.sig'), sig);
310
-
311
- console.log(chalk.green(`\nSigned "${manifest.name}" with your identity key`));
312
- console.log(` Signer: ${chalk.gray(identity.publicKey)}`);
313
- console.log(` Signature: ${chalk.gray(path.join(resolvedPath, 'extension.sig'))}`);
314
- } catch (err) {
315
- console.error(chalk.red(`Sign failed: ${err.message}`));
316
- process.exitCode = 1;
317
- }
318
- });
319
-
320
- cmd
321
- .command('search [query]')
322
- .description('search the extension marketplace')
323
- .option('-s, --sort <sort>', 'Sort by: downloads, rating, newest', 'downloads')
324
- .option('-l, --limit <n>', 'Results per page', '10')
325
- .action(async (query, opts) => {
326
- const { fetchFirst } = await import('../core/discoveryClient.js');
327
- const params = new URLSearchParams();
328
- if (query) params.set('q', query);
329
- params.set('sort', opts.sort);
330
- params.set('limit', opts.limit);
331
-
332
- try {
333
- const res = await fetchFirst(`/api/v1/marketplace?${params}`);
334
- if (!res) {
335
- console.error(chalk.red('Failed to connect to marketplace.'));
336
- process.exitCode = 1;
337
- return;
338
- }
339
- const data = await res.json();
340
- if (!data?.results?.length) {
341
- console.log(chalk.gray('No extensions found.'));
342
- return;
343
- }
344
- console.log('');
345
- console.log(chalk.cyan(`Marketplace (${data.total} results):`));
346
- for (const ext of data.results) {
347
- const verified = ext.verified ? chalk.green(' [Verified]') : '';
348
- const pro = ext.pro ? chalk.yellow(' [Pro]') : '';
349
- const stars = '\u2605'.repeat(Math.round(ext.rating || 0)) + '\u2606'.repeat(5 - Math.round(ext.rating || 0));
350
- console.log(` ${chalk.white(ext.name)} v${ext.version}${verified}${pro}`);
351
- console.log(` ${chalk.gray(ext.description || '')}`);
352
- console.log(` ${chalk.yellow(stars)} (${ext.reviewCount || 0}) \u00b7 ${chalk.cyan(ext.downloads || 0)} downloads \u00b7 by ${chalk.blue('@' + (ext.authorHandle || 'unknown'))}`);
353
- console.log('');
354
- }
355
- console.log(chalk.gray('Install: pe ext install-remote <name>'));
356
- } catch (err) {
357
- console.error(chalk.red(`Search failed: ${err.message}`));
358
- process.exitCode = 1;
359
- }
360
- });
361
-
362
- cmd
363
- .command('install-remote <name>')
364
- .description('install extension from marketplace')
365
- .action(async (name) => {
366
- const { fetchFirst } = await import('../core/discoveryClient.js');
367
- try {
368
- const searchRes = await fetchFirst(`/api/v1/marketplace?q=${encodeURIComponent(name)}&limit=1`);
369
- if (!searchRes) {
370
- console.error(chalk.red('Failed to connect to marketplace.'));
371
- process.exitCode = 1;
372
- return;
373
- }
374
- const data = await searchRes.json();
375
- const ext = data?.results?.find(e => e.name === name);
376
- if (!ext) {
377
- console.error(chalk.red(`Extension "${name}" not found in marketplace.`));
378
- process.exitCode = 1;
379
- return;
380
- }
381
-
382
- const dlRes = await fetchFirst(`/api/v1/marketplace/${ext.id}/download`);
383
- if (!dlRes) {
384
- console.error(chalk.red('Failed to get download URL.'));
385
- process.exitCode = 1;
386
- return;
387
- }
388
- const dlInfo = await dlRes.json();
389
- if (!dlInfo?.bundleUrl) {
390
- console.error(chalk.red('Failed to get download URL.'));
391
- process.exitCode = 1;
392
- return;
393
- }
394
-
395
- console.log(chalk.cyan(`Installing ${name} v${ext.version} from marketplace...`));
396
-
397
- // Download bundle (with SSRF protection)
398
- const { getPrimaryServer } = await import('../core/discoveryClient.js');
399
- const bundleUrl = dlInfo.bundleUrl.startsWith('http') ? dlInfo.bundleUrl : `${getPrimaryServer()}${dlInfo.bundleUrl}`;
400
- try {
401
- const parsed = new URL(bundleUrl);
402
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('Invalid URL scheme');
403
- const host = parsed.hostname;
404
- if (host === 'localhost' || host.startsWith('127.') || host.startsWith('10.') ||
405
- host.startsWith('192.168.') || host.startsWith('169.254.') || host === '0.0.0.0' ||
406
- /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
407
- throw new Error('Bundle URL points to private network');
408
- }
409
- } catch (e) { if (e.message.includes('private') || e.message.includes('scheme')) throw e; }
410
- const bundleRes = await fetch(bundleUrl, { signal: AbortSignal.timeout(30000) });
411
- if (!bundleRes.ok) throw new Error(`Failed to download bundle: ${bundleRes.status}`);
412
- const bundleData = Buffer.from(await bundleRes.arrayBuffer());
413
-
414
- // Verify hash
415
- if (dlInfo.bundleHash) {
416
- const hash = crypto.createHash('sha256').update(bundleData).digest('hex');
417
- if (hash !== dlInfo.bundleHash) throw new Error('Bundle hash mismatch — file may be corrupted');
418
- }
419
-
420
- // Extract to temp dir with zip-slip protection
421
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-ext-dl-'));
422
- const tarPath = path.join(tmpDir, `${name}.tar.gz`);
423
- fs.writeFileSync(tarPath, bundleData);
424
- spawnSync('tar', ['xzf', tarPath, '-C', tmpDir], { stdio: 'pipe' });
425
-
426
- // Zip-slip protection: verify all extracted files are within tmpDir
427
- const realTmpDir = fs.realpathSync(tmpDir);
428
- function validateExtractedPaths(dir) {
429
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
430
- const fullPath = path.join(dir, entry.name);
431
- const realPath = fs.realpathSync(fullPath);
432
- if (!realPath.startsWith(realTmpDir)) {
433
- fs.rmSync(tmpDir, { recursive: true, force: true });
434
- throw new Error(`Zip-slip detected: ${entry.name} escapes extraction directory`);
435
- }
436
- if (entry.isDirectory()) validateExtractedPaths(fullPath);
437
- }
438
- }
439
- validateExtractedPaths(tmpDir);
440
-
441
- // Find the extracted extension directory
442
- const entries = fs.readdirSync(tmpDir).filter(e => e !== path.basename(tarPath));
443
- const extDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir;
444
-
445
- const { installExtension } = await import('../core/extensions.js');
446
- const result = await installExtension(extDir);
447
- fs.rmSync(tmpDir, { recursive: true, force: true });
448
- console.log(chalk.green(`\u2714 Installed "${result.name}" v${result.version || ext.version}`));
449
- } catch (err) {
450
- console.error(chalk.red(`Install failed: ${err.message}`));
451
- process.exitCode = 1;
452
- }
453
- });
454
-
455
- cmd
456
- .command('publish <path>')
457
- .description('publish extension to marketplace')
458
- .action(async (extPath) => {
459
- const { readManifest, validateManifest } = await import('../core/extensions.js');
460
- const { getIdentity } = await import('../core/identity.js');
461
- const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
462
- const sodium = (await import('sodium-native')).default;
463
-
464
- const resolvedPath = path.resolve(extPath);
465
- const manifest = readManifest(resolvedPath);
466
- if (!manifest) {
467
- console.error(chalk.red('No valid extension.json found.'));
468
- process.exitCode = 1;
469
- return;
470
- }
471
-
472
- const validationError = validateManifest(manifest);
473
- if (validationError) {
474
- console.error(chalk.red(`Invalid manifest: ${validationError}`));
475
- process.exitCode = 1;
476
- return;
477
- }
478
-
479
- const identity = await getIdentity();
480
- if (!identity?.publicKey) {
481
- console.error(chalk.red('No identity. Run pe init first.'));
482
- process.exitCode = 1;
483
- return;
484
- }
485
- if (!identity.privateKey) {
486
- console.error(chalk.red('No private key available. Cannot sign bundle.'));
487
- process.exitCode = 1;
488
- return;
489
- }
490
-
491
- const apiKey = await getApiKey();
492
- if (!apiKey) {
493
- console.error(chalk.red('No API key. Create one: pe api-keys create'));
494
- process.exitCode = 1;
495
- return;
496
- }
497
-
498
- // Create .tar.gz bundle
499
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-ext-'));
500
- const bundlePath = path.join(tmpDir, `${manifest.name}-${manifest.version}.tar.gz`);
501
- try {
502
- spawnSync('tar', ['czf', bundlePath, '-C', path.dirname(resolvedPath), path.basename(resolvedPath)], {
503
- stdio: 'pipe',
504
- });
505
- } catch (err) {
506
- console.error(chalk.red(`Failed to create bundle: ${err.message}`));
507
- fs.rmSync(tmpDir, { recursive: true, force: true });
508
- process.exitCode = 1;
509
- return;
510
- }
511
-
512
- // Compute SHA-256 hash of the bundle
513
- const bundleData = fs.readFileSync(bundlePath);
514
- const bundleHash = crypto.createHash('sha256').update(bundleData).digest('hex');
515
-
516
- // Sign the hash with Ed25519
517
- const secretKey = Buffer.from(identity.privateKey, 'hex');
518
- const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
519
- sodium.crypto_sign_detached(sig, Buffer.from(bundleHash), secretKey);
520
- const signature = sig.toString('hex');
521
-
522
- // Read README if present
523
- let readme = '';
524
- for (const name of ['README.md', 'readme.md', 'README', 'readme.txt']) {
525
- const readmePath = path.join(resolvedPath, name);
526
- if (fs.existsSync(readmePath)) {
527
- readme = fs.readFileSync(readmePath, 'utf-8');
528
- break;
529
- }
530
- }
531
-
532
- console.log(chalk.cyan(`Publishing ${manifest.name} v${manifest.version}...`));
533
- console.log(chalk.gray(` Bundle: ${(bundleData.length / 1024).toFixed(1)} KB`));
534
- console.log(chalk.gray(` Hash: ${bundleHash.slice(0, 16)}...`));
535
-
536
- try {
537
- const baseUrl = getPrimaryServer();
538
- const form = new FormData();
539
- form.append('bundle', new Blob([bundleData]), path.basename(bundlePath));
540
- form.append('name', manifest.name);
541
- form.append('version', manifest.version);
542
- form.append('description', manifest.description || '');
543
- form.append('author', manifest.author || '');
544
- form.append('permissions', JSON.stringify(manifest.permissions || []));
545
- form.append('hooks', JSON.stringify(manifest.hooks || []));
546
- form.append('pro', String(manifest.pro || false));
547
- form.append('license', manifest.license || 'MIT');
548
- form.append('readme', readme);
549
- form.append('bundleHash', bundleHash);
550
- form.append('signerPublicKey', manifest.signerPublicKey || identity.publicKey);
551
- form.append('signature', signature);
552
- if (manifest.pricing) form.append('pricing', manifest.pricing);
553
- if (manifest.price != null) form.append('price', String(manifest.price));
554
-
555
- const res = await fetch(`${baseUrl}/api/v1/marketplace/publish`, {
556
- method: 'POST',
557
- headers: { 'x-api-key': apiKey },
558
- body: form,
559
- signal: AbortSignal.timeout(30000),
560
- });
561
-
562
- const result = await res.json();
563
- if (res.ok && result?.success) {
564
- console.log(chalk.green(`\u2714 Published ${manifest.name} v${manifest.version}`));
565
- if (result.url) console.log(chalk.gray(` ${result.url}`));
566
- } else {
567
- console.error(chalk.red(`Publish failed: ${result?.error || 'Unknown error'}`));
568
- process.exitCode = 1;
569
- }
570
- } catch (err) {
571
- console.error(chalk.red(`Publish failed: ${err.message}`));
572
- process.exitCode = 1;
573
- } finally {
574
- fs.rmSync(tmpDir, { recursive: true, force: true });
575
- }
576
- });
577
-
578
- cmd
579
- .command('buy <name>')
580
- .description('purchase a paid extension from the marketplace')
581
- .action(async (name) => {
582
- const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
583
- const { getIdentity } = await import('../core/identity.js');
584
- try {
585
- const searchRes = await fetchFirst(`/api/v1/marketplace?q=${encodeURIComponent(name)}&limit=1`);
586
- if (!searchRes) {
587
- console.error(chalk.red('Failed to connect to marketplace.'));
588
- process.exitCode = 1;
589
- return;
590
- }
591
- const data = await searchRes.json();
592
- const ext = data?.results?.find(e => e.name === name);
593
- if (!ext) {
594
- console.error(chalk.red(`Extension "${name}" not found in marketplace.`));
595
- process.exitCode = 1;
596
- return;
597
- }
598
-
599
- if (!ext.price || ext.price === 0) {
600
- console.log(chalk.gray(`"${name}" is free. Use: pe ext install-remote ${name}`));
601
- return;
602
- }
603
-
604
- console.log('');
605
- console.log(` ${chalk.white(ext.name)} v${ext.version}`);
606
- console.log(` ${chalk.gray(ext.description || '')}`);
607
- console.log(` Price: ${chalk.yellow('$' + ext.price.toFixed(2))}`);
608
- console.log('');
609
-
610
- const ok = await confirm(chalk.white(` Purchase ${ext.name} for $${ext.price.toFixed(2)}? (y/N) `));
611
- if (!ok) {
612
- console.log(chalk.gray(' Cancelled.'));
613
- return;
614
- }
615
-
616
- const identity = await getIdentity();
617
- if (!identity?.publicKey) {
618
- console.error(chalk.red('No identity. Run pe init first.'));
619
- process.exitCode = 1;
620
- return;
621
- }
622
-
623
- const apiKey = await getApiKey();
624
- if (!apiKey) {
625
- console.error(chalk.red('No API key. Create one: pe api-keys create'));
626
- process.exitCode = 1;
627
- return;
628
- }
629
-
630
- const baseUrl = getPrimaryServer();
631
- const checkoutRes = await fetch(`${baseUrl}/api/v1/marketplace/${ext.id}/checkout`, {
632
- method: 'POST',
633
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
634
- signal: AbortSignal.timeout(10000),
635
- });
636
- if (!checkoutRes.ok) {
637
- console.error(chalk.red('Failed to get checkout URL.'));
638
- process.exitCode = 1;
639
- return;
640
- }
641
- const checkout = await checkoutRes.json();
642
- if (checkout.checkoutUrl) {
643
- console.log(chalk.cyan(`\n Opening checkout: ${checkout.checkoutUrl}\n`));
644
- try {
645
- const parsed = new URL(checkout.checkoutUrl);
646
- if (parsed.protocol === 'https:') {
647
- const { exec } = await import('child_process');
648
- exec(`start "" "${parsed.href}"`);
649
- }
650
- } catch {}
651
- }
652
-
653
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
654
- const token = await new Promise(resolve => {
655
- rl.question(chalk.white(' Paste your purchase token: '), answer => {
656
- rl.close();
657
- resolve(answer.trim());
658
- });
659
- });
660
-
661
- if (!token) {
662
- console.log(chalk.gray(' No token provided. Cancelled.'));
663
- return;
664
- }
665
-
666
- const purchaseRes = await fetch(`${baseUrl}/api/v1/marketplace/${ext.id}/purchase`, {
667
- method: 'POST',
668
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
669
- body: JSON.stringify({ paymentToken: token }),
670
- signal: AbortSignal.timeout(10000),
671
- });
672
- if (!purchaseRes.ok) {
673
- const err = await purchaseRes.json().catch(() => ({}));
674
- console.error(chalk.red(`Purchase failed: ${err.error || 'Unknown error'}`));
675
- process.exitCode = 1;
676
- return;
677
- }
678
- const purchaseData = await purchaseRes.json();
679
- if (purchaseData.success && purchaseData.bundleUrl) {
680
- console.log(chalk.cyan(`Installing ${name}...`));
681
- const { installExtension } = await import('../core/extensions.js');
682
- const result = await installExtension(purchaseData.bundleUrl);
683
- console.log(chalk.green(`\u2714 Purchased and installed "${result.name}" v${result.version || ext.version}`));
684
- } else if (purchaseData.success) {
685
- console.log(chalk.green(`\u2714 Purchase confirmed. Install: pe ext install-remote ${name}`));
686
- } else {
687
- console.error(chalk.red(`Purchase failed: ${purchaseData.error || 'Unknown error'}`));
688
- process.exitCode = 1;
689
- }
690
- } catch (err) {
691
- console.error(chalk.red(`Buy failed: ${err.message}`));
692
- process.exitCode = 1;
693
- }
694
- });
695
-
696
- cmd
697
- .command('earnings')
698
- .description('show developer earnings from published extensions')
699
- .action(async () => {
700
- const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
701
- const { getIdentity } = await import('../core/identity.js');
702
- try {
703
- const identity = await getIdentity();
704
- if (!identity?.publicKey) {
705
- console.error(chalk.red('No identity. Run pe init first.'));
706
- process.exitCode = 1;
707
- return;
708
- }
709
- const apiKey = await getApiKey();
710
- if (!apiKey) {
711
- console.error(chalk.red('No API key. Create one: pe api-keys create'));
712
- process.exitCode = 1;
713
- return;
714
- }
715
- const baseUrl = getPrimaryServer();
716
- const res = await fetch(`${baseUrl}/api/v1/marketplace/earnings`, {
717
- headers: { 'x-api-key': apiKey },
718
- signal: AbortSignal.timeout(10000),
719
- });
720
- if (!res.ok) {
721
- console.error(chalk.red('Failed to fetch earnings.'));
722
- process.exitCode = 1;
723
- return;
724
- }
725
- const data = await res.json();
726
- console.log('');
727
- console.log(chalk.cyan.bold('Developer Earnings'));
728
- console.log(chalk.gray('\u2500'.repeat(40)));
729
- console.log(` Total Revenue: ${chalk.white('$' + (data.totalRevenue || 0).toFixed(2))}`);
730
- console.log(` Available: ${chalk.green('$' + (data.available || 0).toFixed(2))}`);
731
- console.log(` Paid Out: ${chalk.gray('$' + (data.paidOut || 0).toFixed(2))}`);
732
-
733
- if (data.recentSales?.length) {
734
- console.log('');
735
- console.log(chalk.cyan('Recent Sales:'));
736
- for (const sale of data.recentSales) {
737
- console.log(` ${chalk.gray(new Date(sale.date).toLocaleDateString())} ${chalk.white(sale.extName)} $${sale.amount.toFixed(2)}`);
738
- }
739
- }
740
-
741
- if (data.payouts?.length) {
742
- console.log('');
743
- console.log(chalk.cyan('Payout History:'));
744
- for (const p of data.payouts) {
745
- const statusColor = p.status === 'paid' ? chalk.green : p.status === 'pending' ? chalk.yellow : chalk.gray;
746
- console.log(` ${chalk.gray(new Date(p.date).toLocaleDateString())} $${p.amount.toFixed(2)} ${statusColor(p.status)} ${chalk.gray(p.email)}`);
747
- }
748
- }
749
- console.log('');
750
- } catch (err) {
751
- console.error(chalk.red(`Earnings failed: ${err.message}`));
752
- process.exitCode = 1;
753
- }
754
- });
755
-
756
- cmd
757
- .command('my-extensions')
758
- .description('list your published extensions with stats')
759
- .action(async () => {
760
- const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
761
- const { getIdentity } = await import('../core/identity.js');
762
- try {
763
- const identity = await getIdentity();
764
- if (!identity?.publicKey) {
765
- console.error(chalk.red('No identity. Run pe init first.'));
766
- process.exitCode = 1;
767
- return;
768
- }
769
- const apiKey = await getApiKey();
770
- if (!apiKey) {
771
- console.error(chalk.red('No API key. Create one: pe api-keys create'));
772
- process.exitCode = 1;
773
- return;
774
- }
775
- const baseUrl = getPrimaryServer();
776
- const res = await fetch(`${baseUrl}/api/v1/marketplace/my-extensions`, {
777
- headers: { 'x-api-key': apiKey },
778
- signal: AbortSignal.timeout(10000),
779
- });
780
- if (!res.ok) {
781
- console.error(chalk.red('Failed to fetch extensions.'));
782
- process.exitCode = 1;
783
- return;
784
- }
785
- const data = await res.json();
786
- if (!data.extensions?.length) {
787
- console.log(chalk.gray('No published extensions.'));
788
- console.log(chalk.gray(' pe ext publish <path>'));
789
- return;
790
- }
791
- console.log('');
792
- console.log(chalk.cyan('My Published Extensions:'));
793
- console.log(chalk.gray('\u2500'.repeat(60)));
794
- for (const ext of data.extensions) {
795
- const price = ext.price ? chalk.yellow(`$${ext.price.toFixed(2)}`) : chalk.green('Free');
796
- const stars = '\u2605'.repeat(Math.round(ext.rating || 0)) + '\u2606'.repeat(5 - Math.round(ext.rating || 0));
797
- console.log(` ${chalk.white(ext.name)} v${ext.version} ${price}`);
798
- console.log(` ${chalk.yellow(stars)} (${ext.reviewCount || 0}) \u00b7 ${chalk.cyan(ext.downloads || 0)} downloads \u00b7 ${chalk.green(ext.purchases || 0)} purchases`);
799
- console.log(` Revenue: ${chalk.green('$' + (ext.revenue || 0).toFixed(2))}`);
800
- console.log('');
801
- }
802
- } catch (err) {
803
- console.error(chalk.red(`Failed: ${err.message}`));
804
- process.exitCode = 1;
805
- }
806
- });
807
-
808
- cmd
809
- .command('create <name>')
810
- .description('scaffold a new extension')
811
- .action(async (extName) => {
812
- const dir = path.resolve(extName);
813
- const baseName = path.basename(dir);
814
- if (fs.existsSync(dir)) {
815
- console.log(chalk.red(`Directory "${extName}" already exists.`));
816
- process.exitCode = 1;
817
- return;
818
- }
819
-
820
- fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
821
- fs.mkdirSync(path.join(dir, 'test'), { recursive: true });
822
-
823
- const manifest = {
824
- name: baseName,
825
- version: '1.0.0',
826
- description: `${baseName} extension for Palexplorer`,
827
- author: '',
828
- license: 'MIT',
829
- main: 'index.js',
830
- hooks: ['on:app:ready'],
831
- permissions: ['config:read'],
832
- config: {
833
- enabled: { type: 'boolean', default: true, description: 'Enable this extension' },
834
- },
835
- pro: false,
836
- minAppVersion: '0.5.0',
837
- };
838
-
839
- fs.writeFileSync(
840
- path.join(dir, 'extension.json'),
841
- JSON.stringify(manifest, null, 2) + '\n'
842
- );
843
-
844
- fs.writeFileSync(path.join(dir, 'index.js'), `let ctx = null;
845
-
846
- export function activate(context) {
847
- ctx = context;
848
-
849
- context.hooks.on('on:app:ready', async () => {
850
- context.logger.info('${baseName} ready');
851
- });
852
- }
853
-
854
- export function deactivate() {
855
- ctx = null;
856
- }
857
- `);
858
-
859
- fs.writeFileSync(path.join(dir, 'test', `${baseName}.test.js`), `import { describe, it, beforeEach, mock } from 'node:test';
860
- import assert from 'node:assert/strict';
861
-
862
- function createMockContext(overrides = {}) {
863
- return {
864
- hooks: { on: mock.fn() },
865
- config: { get: mock.fn(), set: mock.fn() },
866
- store: { get: mock.fn(), set: mock.fn(), delete: mock.fn() },
867
- logger: { info: mock.fn(), warn: mock.fn(), error: mock.fn() },
868
- app: { version: '0.5.0', platform: 'linux', dataDir: '/tmp/test' },
869
- ...overrides,
870
- };
871
- }
872
-
873
- describe('${baseName}', () => {
874
- let ext;
875
- let ctx;
876
-
877
- beforeEach(async () => {
878
- ext = await import('../index.js');
879
- ctx = createMockContext();
880
- });
881
-
882
- it('should activate and register hooks', () => {
883
- ext.activate(ctx);
884
- assert.ok(ctx.hooks.on.mock.calls.length > 0);
885
- });
886
-
887
- it('should deactivate cleanly', () => {
888
- ext.activate(ctx);
889
- ext.deactivate();
890
- });
891
- });
892
- `);
893
-
894
- fs.writeFileSync(path.join(dir, 'README.md'), `# ${baseName}
895
-
896
- Brief description of what this extension does.
897
-
898
- ## Configuration
899
-
900
- | Key | Type | Default | Description |
901
- |-----|------|---------|-------------|
902
- | \`enabled\` | boolean | \`true\` | Enable this extension |
903
-
904
- \`\`\`bash
905
- pe ext config ${baseName} enabled true
906
- \`\`\`
907
- `);
908
-
909
- fs.writeFileSync(path.join(dir, 'docs', 'PLAN.md'), `# ${baseName} — Plan
910
-
911
- ## Goal
912
-
913
- What problem does this extension solve?
914
-
915
- ## Design
916
-
917
- - How it hooks into core
918
- - Key decisions and trade-offs
919
- `);
920
-
921
- fs.writeFileSync(path.join(dir, 'docs', 'MONETIZATION.md'), `# ${baseName} Monetization
922
-
923
- ## Tier
924
-
925
- - [ ] Free
926
- - [ ] Pro
927
- - [ ] Enterprise
928
-
929
- ## Rationale
930
-
931
- Why this tier?
932
- `);
933
-
934
- console.log(chalk.green(`✔ Scaffolded extension at ./${baseName}/`));
935
- console.log(chalk.gray(' Files created:'));
936
- console.log(chalk.gray(' extension.json'));
937
- console.log(chalk.gray(' index.js'));
938
- console.log(chalk.gray(' README.md'));
939
- console.log(chalk.gray(' docs/PLAN.md'));
940
- console.log(chalk.gray(' docs/MONETIZATION.md'));
941
- console.log(chalk.gray(` test/${baseName}.test.js`));
942
- console.log('');
943
- console.log(chalk.cyan(' Install it:'));
944
- console.log(chalk.white(` pe ext install ./${baseName}`));
945
- });
946
- }
947
-
948
- async function listExtensions() {
949
- const { getInstalledExtensions } = await import('../core/extensions.js');
950
- const extensions = getInstalledExtensions();
951
- if (extensions.length === 0) {
952
- console.log(chalk.gray('No extensions installed.'));
953
- console.log(chalk.gray(' pe ext install <path|git-url>'));
954
- console.log(chalk.gray(' pe ext create <name>'));
955
- return;
956
- }
957
-
958
- console.log('');
959
- console.log(chalk.cyan('Extensions:'));
960
- for (const ext of extensions) {
961
- const name = ext.bundled ? chalk.blue(`@palexplorer/${ext.name}`) : chalk.white(ext.name);
962
- const status = ext.enabled ? chalk.green('●') : chalk.red('○');
963
- const version = chalk.gray(`v${ext.version}`);
964
- const tier = ext.tier || (ext.pro ? 'pro' : 'free');
965
- const tierBadge = tier === 'enterprise' ? chalk.magenta(' [Enterprise]')
966
- : tier === 'pro' ? chalk.yellow(' [Pro]') : '';
967
- console.log(` ${status} ${name} ${version}${tierBadge}`);
968
- if (ext.description) console.log(` ${chalk.gray(ext.description)}`);
969
- }
970
- }
1
+ import chalk from 'chalk';
2
+ import crypto from 'crypto';
3
+ import { execSync, spawnSync } from 'child_process';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import readline from 'readline';
8
+ import config from '../utils/config.js';
9
+
10
+ async function getApiKey() {
11
+ if (process.env.PAL_API_KEY) return process.env.PAL_API_KEY;
12
+ try {
13
+ const keytar = (await import('keytar')).default;
14
+ const key = await keytar.getPassword('palexplorer', 'apiKey');
15
+ if (key) return key;
16
+ } catch {}
17
+ return config.get('apiKey');
18
+ }
19
+
20
+ function confirm(question) {
21
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
22
+ return new Promise(resolve => {
23
+ rl.question(question, answer => {
24
+ rl.close();
25
+ resolve(answer.toLowerCase().startsWith('y'));
26
+ });
27
+ });
28
+ }
29
+
30
+ export default function extensionCommand(program) {
31
+ const cmdName = process.argv[2] === 'extension' ? 'extension' : 'ext';
32
+ const cmd = program
33
+ .command(cmdName)
34
+ .description('manage extensions (install, remove, enable, disable)')
35
+ .addHelpText('after', `
36
+ Examples:
37
+ $ pal ext list List installed extensions
38
+ $ pal ext install ./my-extension Install from local path
39
+ $ pal ext install https://github.com/user/pe-ext-slack.git
40
+ $ pal ext remove slack-notify Uninstall an extension
41
+ $ pal ext enable slack-notify Enable a disabled extension
42
+ $ pal ext disable slack-notify Disable without uninstalling
43
+ $ pal ext info slack-notify Show extension details
44
+ $ pal ext config slack-notify webhookUrl https://hooks.slack.com/...
45
+ $ pal ext create my-extension Scaffold a new extension
46
+ $ pal ext audit Security audit all extensions
47
+ `)
48
+ .action(async () => {
49
+ await listExtensions();
50
+ });
51
+
52
+ cmd
53
+ .command('list')
54
+ .description('list installed extensions')
55
+ .action(async () => { await listExtensions(); });
56
+
57
+ cmd
58
+ .command('install <source>')
59
+ .description('install extension from path or git URL')
60
+ .option('-y, --yes', 'Skip confirmation prompt')
61
+ .action(async (source, opts) => {
62
+ try {
63
+ const { installExtension, HIGH_RISK_PERMISSIONS } = await import('../core/extensions.js');
64
+ console.log(chalk.cyan(`Installing extension from ${source}...`));
65
+ const result = await installExtension(source);
66
+
67
+ // Show security info
68
+ const riskColor = result.risk === 'high' ? chalk.red : result.risk === 'medium' ? chalk.yellow : chalk.green;
69
+ console.log('');
70
+ console.log(` Name: ${chalk.white(result.name)} v${result.version}`);
71
+ console.log(` Risk: ${riskColor(result.risk.toUpperCase())}`);
72
+ console.log(` Signed: ${result.signature?.verified ? chalk.green('verified ✔') : chalk.yellow('not signed')}`);
73
+
74
+ if (result.permissions?.length) {
75
+ console.log(` Permissions:`);
76
+ for (const p of result.permissions) {
77
+ const isHigh = HIGH_RISK_PERMISSIONS.has(p) || p.startsWith('exec:');
78
+ console.log(` ${isHigh ? chalk.red('⚠') : chalk.gray('·')} ${isHigh ? chalk.red(p) : p}`);
79
+ }
80
+ }
81
+
82
+ // Require confirmation for unsigned or high-risk extensions
83
+ if (!opts.yes && (result.risk !== 'low' || !result.signature?.verified)) {
84
+ console.log('');
85
+ if (!result.signature?.verified) {
86
+ console.log(chalk.yellow(' ⚠ This extension is NOT signed. It has not been reviewed by the Palexplorer team.'));
87
+ }
88
+ if (result.risk === 'high') {
89
+ console.log(chalk.red(' ⚠ HIGH RISK: This extension requests dangerous permissions.'));
90
+ }
91
+ const ok = await confirm(chalk.white('\n Proceed with installation? (y/N) '));
92
+ if (!ok) {
93
+ // Remove the already-copied files
94
+ const { removeExtension } = await import('../core/extensions.js');
95
+ try { removeExtension(result.name); } catch {}
96
+ console.log(chalk.gray(' Installation cancelled.'));
97
+ return;
98
+ }
99
+ }
100
+
101
+ console.log(chalk.green(`\n✔ Installed "${result.name}" v${result.version}`));
102
+ } catch (err) {
103
+ console.error(chalk.red(`Install failed: ${err.message}`));
104
+ process.exitCode = 1;
105
+ }
106
+ });
107
+
108
+ cmd
109
+ .command('remove <name>')
110
+ .description('uninstall an extension')
111
+ .action(async (extName) => {
112
+ try {
113
+ const { removeExtension } = await import('../core/extensions.js');
114
+ removeExtension(extName);
115
+ console.log(chalk.green(`✔ Removed "${extName}"`));
116
+ } catch (err) {
117
+ console.error(chalk.red(`Remove failed: ${err.message}`));
118
+ process.exitCode = 1;
119
+ }
120
+ });
121
+
122
+ cmd
123
+ .command('enable <name>')
124
+ .description('enable a disabled extension')
125
+ .action(async (extName) => {
126
+ const { enableExtension } = await import('../core/extensions.js');
127
+ enableExtension(extName);
128
+ console.log(chalk.green(`✔ Enabled "${extName}"`));
129
+ });
130
+
131
+ cmd
132
+ .command('disable <name>')
133
+ .description('disable an extension without uninstalling')
134
+ .action(async (extName) => {
135
+ const { disableExtension } = await import('../core/extensions.js');
136
+ disableExtension(extName);
137
+ console.log(chalk.green(`✔ Disabled "${extName}"`));
138
+ });
139
+
140
+ cmd
141
+ .command('info <name>')
142
+ .description('show extension details')
143
+ .action(async (extName) => {
144
+ const { getInstalledExtensions, getExtensionConfig } = await import('../core/extensions.js');
145
+ const extensions = getInstalledExtensions();
146
+ const ext = extensions.find(e => e.name === extName || `@palexplorer/${e.name}` === extName);
147
+ if (!ext) {
148
+ console.log(chalk.red(`Extension "${extName}" not found.`));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ console.log('');
153
+ console.log(chalk.cyan(ext.bundled ? `@palexplorer/${ext.name}` : ext.name));
154
+ console.log(` Version: ${chalk.white(ext.version)}`);
155
+ console.log(` Description: ${chalk.gray(ext.description || 'N/A')}`);
156
+ console.log(` Author: ${chalk.gray(ext.author || 'N/A')}`);
157
+ console.log(` Status: ${ext.enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
158
+ console.log(` Bundled: ${ext.bundled ? chalk.blue('yes') : 'no'}`);
159
+ const tier = ext.tier || (ext.pro ? 'pro' : 'free');
160
+ const tierColor = tier === 'enterprise' ? chalk.magenta : tier === 'pro' ? chalk.yellow : chalk.green;
161
+ console.log(` Tier: ${tierColor(tier.toUpperCase())}${ext.price ? ` (${ext.price})` : ''}`);
162
+ if (ext.hooks?.length) {
163
+ console.log(` Hooks: ${chalk.gray(ext.hooks.join(', '))}`);
164
+ }
165
+ if (ext.permissions?.length) {
166
+ console.log(` Permissions:`);
167
+ for (const p of ext.permissions) {
168
+ console.log(` - ${p}`);
169
+ }
170
+ }
171
+ const cfg = getExtensionConfig(extName);
172
+ if (Object.keys(cfg).length) {
173
+ console.log(` Config:`);
174
+ for (const [k, v] of Object.entries(cfg)) {
175
+ console.log(` ${k}: ${chalk.white(JSON.stringify(v))}`);
176
+ }
177
+ }
178
+ });
179
+
180
+ cmd
181
+ .command('help <name>')
182
+ .description('show extension help and usage')
183
+ .action(async (extName) => {
184
+ const { getInstalledExtensions, getExtensionConfig, readManifest } = await import('../core/extensions.js');
185
+ const extensions = getInstalledExtensions();
186
+ const ext = extensions.find(e => e.name === extName || `@palexplorer/${e.name}` === extName);
187
+ if (!ext) {
188
+ console.log(chalk.red(`Extension "${extName}" not found.`));
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+
193
+ const manifest = readManifest(ext.path);
194
+ const help = manifest?.help;
195
+ const fullName = ext.bundled ? `@palexplorer/${ext.name}` : ext.name;
196
+
197
+ console.log('');
198
+ console.log(chalk.cyan.bold(fullName) + chalk.gray(` v${ext.version}`));
199
+ console.log(chalk.gray(''.repeat(50)));
200
+
201
+ // Summary / description
202
+ console.log(chalk.white(help?.summary || ext.description || 'No description.'));
203
+ console.log('');
204
+
205
+ // Usage
206
+ if (help?.usage) {
207
+ console.log(chalk.yellow.bold('Usage'));
208
+ console.log(` ${help.usage}`);
209
+ console.log('');
210
+ }
211
+
212
+ // Examples
213
+ if (help?.examples?.length) {
214
+ console.log(chalk.yellow.bold('Examples'));
215
+ for (const ex of help.examples) {
216
+ console.log(` ${chalk.green('$')} ${ex}`);
217
+ }
218
+ console.log('');
219
+ }
220
+
221
+ // Config reference
222
+ const configSchema = manifest?.config;
223
+ if (help?.configReference && Object.keys(help.configReference).length) {
224
+ console.log(chalk.yellow.bold('Configuration'));
225
+ for (const [key, desc] of Object.entries(help.configReference)) {
226
+ const def = configSchema?.[key]?.default;
227
+ const defStr = def !== undefined ? chalk.gray(` (default: ${JSON.stringify(def)})`) : '';
228
+ console.log(` ${chalk.white(key)}${defStr}`);
229
+ console.log(` ${chalk.gray(desc)}`);
230
+ }
231
+ console.log('');
232
+ } else if (configSchema && Object.keys(configSchema).length) {
233
+ console.log(chalk.yellow.bold('Configuration'));
234
+ for (const [key, schema] of Object.entries(configSchema)) {
235
+ const def = schema.default !== undefined ? chalk.gray(` (default: ${JSON.stringify(schema.default)})`) : '';
236
+ console.log(` ${chalk.white(key)}${def}`);
237
+ if (schema.description) console.log(` ${chalk.gray(schema.description)}`);
238
+ }
239
+ console.log('');
240
+ }
241
+
242
+ // Links
243
+ if (help?.links && Object.keys(help.links).length) {
244
+ console.log(chalk.yellow.bold('Links'));
245
+ for (const [label, url] of Object.entries(help.links)) {
246
+ console.log(` ${chalk.white(label)}: ${chalk.blue(url)}`);
247
+ }
248
+ console.log('');
249
+ }
250
+
251
+ // Quick commands
252
+ console.log(chalk.gray(` pal ext info ${ext.name} Show metadata`));
253
+ console.log(chalk.gray(` pal ext config ${ext.name} <k> <v> Set config`));
254
+ console.log(chalk.gray(` pal ext disable ${ext.name} Disable`));
255
+ });
256
+
257
+ cmd
258
+ .command('config <name> <key> [value]')
259
+ .description('get or set extension config value')
260
+ .action(async (extName, key, value) => {
261
+ const { getExtensionConfig, setExtensionConfig } = await import('../core/extensions.js');
262
+ if (value === undefined) {
263
+ const cfg = getExtensionConfig(extName);
264
+ console.log(cfg[key] !== undefined ? JSON.stringify(cfg[key]) : chalk.gray('(not set)'));
265
+ } else {
266
+ // Try to parse as JSON, fallback to string
267
+ let parsed = value;
268
+ try { parsed = JSON.parse(value); } catch {}
269
+ setExtensionConfig(extName, key, parsed);
270
+ console.log(chalk.green(`✔ ${extName}.${key} = ${JSON.stringify(parsed)}`));
271
+ }
272
+ });
273
+
274
+ cmd
275
+ .command('audit')
276
+ .description('security audit all installed extensions')
277
+ .action(async () => {
278
+ const {
279
+ getInstalledExtensions, verifySignature, computeIntegrityHash,
280
+ getSecurityRisk, hasBlockedImports, scanDangerousPatterns, BLOCKED_MODULES,
281
+ } = await import('../core/extensions.js');
282
+ const config = (await import('../utils/config.js')).default;
283
+ const extensions = getInstalledExtensions();
284
+
285
+ if (extensions.length === 0) {
286
+ console.log(chalk.gray('No extensions installed.'));
287
+ return;
288
+ }
289
+
290
+ console.log('');
291
+ console.log(chalk.cyan.bold('Extension Security Audit'));
292
+ console.log(chalk.gray('─'.repeat(60)));
293
+ let issues = 0;
294
+
295
+ for (const ext of extensions) {
296
+ const fullName = ext.bundled ? `@palexplorer/${ext.name}` : ext.name;
297
+ const sig = verifySignature(ext.path, ext);
298
+ const risk = getSecurityRisk(ext);
299
+ const mainPath = path.join(ext.path, ext.main || 'index.js');
300
+ const blocked = hasBlockedImports(mainPath);
301
+ const storedHash = config.get(`ext_integrity.${ext.name}`);
302
+ const currentHash = computeIntegrityHash(ext.path, ext);
303
+ const tampered = storedHash && storedHash !== currentHash;
304
+
305
+ const riskColor = risk === 'high' ? chalk.red : risk === 'medium' ? chalk.yellow : chalk.green;
306
+ console.log('');
307
+ console.log(` ${ext.bundled ? chalk.blue(fullName) : chalk.white(fullName)} v${ext.version}`);
308
+ console.log(` Status: ${ext.enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
309
+ console.log(` Risk: ${riskColor(risk)}`);
310
+ console.log(` Signed: ${sig.verified ? chalk.green('verified ✔') : sig.signed ? chalk.red('INVALID ✖') : chalk.yellow('unsigned')}`);
311
+ console.log(` Integrity: ${tampered ? chalk.red('TAMPERED ✖') : storedHash ? chalk.green('OK ✔') : chalk.gray('no baseline')}`);
312
+
313
+ const dangerous = !ext.bundled ? scanDangerousPatterns(mainPath) : [];
314
+
315
+ if (blocked) {
316
+ console.log(` ${chalk.red(`⚠ BLOCKED: imports '${blocked}'`)}`);
317
+ issues++;
318
+ }
319
+ if (dangerous.length > 0) {
320
+ console.log(` ${chalk.yellow(`⚠ Dangerous patterns: ${dangerous.join(', ')}`)}`);
321
+ issues++;
322
+ }
323
+ if (tampered) {
324
+ console.log(` ${chalk.red(' Files modified since installation!')}`);
325
+ issues++;
326
+ }
327
+ if (!ext.bundled && !sig.verified) {
328
+ console.log(` ${chalk.yellow('⚠ Not signed — not reviewed by Palexplorer team')}`);
329
+ issues++;
330
+ }
331
+ if (risk === 'high') {
332
+ const highPerms = (ext.permissions || []).filter(p =>
333
+ ['fs:write', 'fs:delete', 'net:http', 'net:ws', 'messages:send', 'shares:write'].includes(p) || p.startsWith('exec:')
334
+ );
335
+ console.log(` ${chalk.red(`⚠ High-risk permissions: ${highPerms.join(', ')}`)}`);
336
+ issues++;
337
+ }
338
+ }
339
+
340
+ console.log('');
341
+ console.log(chalk.gray('─'.repeat(60)));
342
+ if (issues === 0) {
343
+ console.log(chalk.green(`✔ No security issues found across ${extensions.length} extension(s).`));
344
+ } else {
345
+ console.log(chalk.yellow(`⚠ ${issues} issue(s) found across ${extensions.length} extension(s).`));
346
+ }
347
+ console.log('');
348
+ });
349
+
350
+ cmd
351
+ .command('sign <extPath>')
352
+ .description('sign an extension with your identity key')
353
+ .action(async (extPath) => {
354
+ try {
355
+ const { readManifest } = await import('../core/extensions.js');
356
+ const { getIdentity } = await import('../core/identity.js');
357
+ const sodium = (await import('sodium-native')).default;
358
+
359
+ const resolvedPath = path.resolve(extPath);
360
+ const manifest = readManifest(resolvedPath);
361
+ if (!manifest) {
362
+ console.error(chalk.red('No valid extension.json found at path'));
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+
367
+ const identity = await getIdentity();
368
+ if (!identity || !identity.privateKey) {
369
+ console.error(chalk.red('No identity found. Run "pal init" first.'));
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+
374
+ manifest.signerPublicKey = identity.publicKey;
375
+ const manifestJson = JSON.stringify(manifest, null, 2) + '\n';
376
+ fs.writeFileSync(path.join(resolvedPath, 'extension.json'), manifestJson);
377
+
378
+ const manifestData = Buffer.from(manifestJson);
379
+ const mainData = fs.readFileSync(path.join(resolvedPath, manifest.main || 'index.js'));
380
+ const content = Buffer.concat([manifestData, mainData]);
381
+
382
+ const secretKey = Buffer.from(identity.privateKey, 'hex');
383
+ const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
384
+ sodium.crypto_sign_detached(sig, content, secretKey);
385
+
386
+ fs.writeFileSync(path.join(resolvedPath, 'extension.sig'), sig);
387
+
388
+ console.log(chalk.green(`\nSigned "${manifest.name}" with your identity key`));
389
+ console.log(` Signer: ${chalk.gray(identity.publicKey)}`);
390
+ console.log(` Signature: ${chalk.gray(path.join(resolvedPath, 'extension.sig'))}`);
391
+ } catch (err) {
392
+ console.error(chalk.red(`Sign failed: ${err.message}`));
393
+ process.exitCode = 1;
394
+ }
395
+ });
396
+
397
+ cmd
398
+ .command('search [query]')
399
+ .description('search the extension marketplace')
400
+ .option('-s, --sort <sort>', 'Sort by: downloads, rating, newest', 'downloads')
401
+ .option('-l, --limit <n>', 'Results per page', '10')
402
+ .action(async (query, opts) => {
403
+ const { fetchFirst } = await import('../core/discoveryClient.js');
404
+ const params = new URLSearchParams();
405
+ if (query) params.set('q', query);
406
+ params.set('sort', opts.sort);
407
+ params.set('limit', opts.limit);
408
+
409
+ try {
410
+ const res = await fetchFirst(`/api/v1/marketplace?${params}`);
411
+ if (!res) {
412
+ console.error(chalk.red('Failed to connect to marketplace.'));
413
+ process.exitCode = 1;
414
+ return;
415
+ }
416
+ const data = await res.json();
417
+ if (!data?.results?.length) {
418
+ console.log(chalk.gray('No extensions found.'));
419
+ return;
420
+ }
421
+ console.log('');
422
+ console.log(chalk.cyan(`Marketplace (${data.total} results):`));
423
+ for (const ext of data.results) {
424
+ const verified = ext.verified ? chalk.green(' [Verified]') : '';
425
+ const pro = ext.pro ? chalk.yellow(' [Pro]') : '';
426
+ const stars = '\u2605'.repeat(Math.round(ext.rating || 0)) + '\u2606'.repeat(5 - Math.round(ext.rating || 0));
427
+ console.log(` ${chalk.white(ext.name)} v${ext.version}${verified}${pro}`);
428
+ console.log(` ${chalk.gray(ext.description || '')}`);
429
+ console.log(` ${chalk.yellow(stars)} (${ext.reviewCount || 0}) \u00b7 ${chalk.cyan(ext.downloads || 0)} downloads \u00b7 by ${chalk.blue('@' + (ext.authorHandle || 'unknown'))}`);
430
+ console.log('');
431
+ }
432
+ console.log(chalk.gray('Install: pal ext install-remote <name>'));
433
+ } catch (err) {
434
+ console.error(chalk.red(`Search failed: ${err.message}`));
435
+ process.exitCode = 1;
436
+ }
437
+ });
438
+
439
+ cmd
440
+ .command('install-remote <name>')
441
+ .description('install extension from marketplace')
442
+ .action(async (name) => {
443
+ const { fetchFirst } = await import('../core/discoveryClient.js');
444
+ try {
445
+ const searchRes = await fetchFirst(`/api/v1/marketplace?q=${encodeURIComponent(name)}&limit=1`);
446
+ if (!searchRes) {
447
+ console.error(chalk.red('Failed to connect to marketplace.'));
448
+ process.exitCode = 1;
449
+ return;
450
+ }
451
+ const data = await searchRes.json();
452
+ const ext = data?.results?.find(e => e.name === name);
453
+ if (!ext) {
454
+ console.error(chalk.red(`Extension "${name}" not found in marketplace.`));
455
+ process.exitCode = 1;
456
+ return;
457
+ }
458
+
459
+ const dlRes = await fetchFirst(`/api/v1/marketplace/${ext.id}/download`);
460
+ if (!dlRes) {
461
+ console.error(chalk.red('Failed to get download URL.'));
462
+ process.exitCode = 1;
463
+ return;
464
+ }
465
+ const dlInfo = await dlRes.json();
466
+ if (!dlInfo?.bundleUrl) {
467
+ console.error(chalk.red('Failed to get download URL.'));
468
+ process.exitCode = 1;
469
+ return;
470
+ }
471
+
472
+ console.log(chalk.cyan(`Installing ${name} v${ext.version} from marketplace...`));
473
+
474
+ // Download bundle (with SSRF protection)
475
+ const { getPrimaryServer } = await import('../core/discoveryClient.js');
476
+ const bundleUrl = dlInfo.bundleUrl.startsWith('http') ? dlInfo.bundleUrl : `${getPrimaryServer()}${dlInfo.bundleUrl}`;
477
+ try {
478
+ const parsed = new URL(bundleUrl);
479
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('Invalid URL scheme');
480
+ const host = parsed.hostname;
481
+ if (host === 'localhost' || host.startsWith('127.') || host.startsWith('10.') ||
482
+ host.startsWith('192.168.') || host.startsWith('169.254.') || host === '0.0.0.0' ||
483
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
484
+ throw new Error('Bundle URL points to private network');
485
+ }
486
+ } catch (e) { if (e.message.includes('private') || e.message.includes('scheme')) throw e; }
487
+ const bundleRes = await fetch(bundleUrl, { signal: AbortSignal.timeout(30000) });
488
+ if (!bundleRes.ok) throw new Error(`Failed to download bundle: ${bundleRes.status}`);
489
+ const bundleData = Buffer.from(await bundleRes.arrayBuffer());
490
+
491
+ // Verify hash
492
+ if (dlInfo.bundleHash) {
493
+ const hash = crypto.createHash('sha256').update(bundleData).digest('hex');
494
+ if (hash !== dlInfo.bundleHash) throw new Error('Bundle hash mismatch — file may be corrupted');
495
+ }
496
+
497
+ // Extract to temp dir with zip-slip protection
498
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-ext-dl-'));
499
+ const tarPath = path.join(tmpDir, `${name}.tar.gz`);
500
+ fs.writeFileSync(tarPath, bundleData);
501
+ spawnSync('tar', ['xzf', tarPath, '-C', tmpDir], { stdio: 'pipe' });
502
+
503
+ // Zip-slip protection: verify all extracted files are within tmpDir
504
+ const realTmpDir = fs.realpathSync(tmpDir);
505
+ function validateExtractedPaths(dir) {
506
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
507
+ const fullPath = path.join(dir, entry.name);
508
+ const realPath = fs.realpathSync(fullPath);
509
+ if (!realPath.startsWith(realTmpDir)) {
510
+ fs.rmSync(tmpDir, { recursive: true, force: true });
511
+ throw new Error(`Zip-slip detected: ${entry.name} escapes extraction directory`);
512
+ }
513
+ if (entry.isDirectory()) validateExtractedPaths(fullPath);
514
+ }
515
+ }
516
+ validateExtractedPaths(tmpDir);
517
+
518
+ // Find the extracted extension directory
519
+ const entries = fs.readdirSync(tmpDir).filter(e => e !== path.basename(tarPath));
520
+ const extDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir;
521
+
522
+ const { installExtension } = await import('../core/extensions.js');
523
+ const result = await installExtension(extDir);
524
+ fs.rmSync(tmpDir, { recursive: true, force: true });
525
+ console.log(chalk.green(`\u2714 Installed "${result.name}" v${result.version || ext.version}`));
526
+ } catch (err) {
527
+ console.error(chalk.red(`Install failed: ${err.message}`));
528
+ process.exitCode = 1;
529
+ }
530
+ });
531
+
532
+ cmd
533
+ .command('publish <path>')
534
+ .description('publish extension to marketplace')
535
+ .action(async (extPath) => {
536
+ const { readManifest, validateManifest } = await import('../core/extensions.js');
537
+ const { getIdentity } = await import('../core/identity.js');
538
+ const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
539
+ const sodium = (await import('sodium-native')).default;
540
+
541
+ const resolvedPath = path.resolve(extPath);
542
+ const manifest = readManifest(resolvedPath);
543
+ if (!manifest) {
544
+ console.error(chalk.red('No valid extension.json found.'));
545
+ process.exitCode = 1;
546
+ return;
547
+ }
548
+
549
+ const validationError = validateManifest(manifest);
550
+ if (validationError) {
551
+ console.error(chalk.red(`Invalid manifest: ${validationError}`));
552
+ process.exitCode = 1;
553
+ return;
554
+ }
555
+
556
+ const identity = await getIdentity();
557
+ if (!identity?.publicKey) {
558
+ console.error(chalk.red('No identity. Run pal init first.'));
559
+ process.exitCode = 1;
560
+ return;
561
+ }
562
+ if (!identity.privateKey) {
563
+ console.error(chalk.red('No private key available. Cannot sign bundle.'));
564
+ process.exitCode = 1;
565
+ return;
566
+ }
567
+
568
+ const apiKey = await getApiKey();
569
+ if (!apiKey) {
570
+ console.error(chalk.red('No API key. Create one: pal api-keys create'));
571
+ process.exitCode = 1;
572
+ return;
573
+ }
574
+
575
+ // Create .tar.gz bundle
576
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-ext-'));
577
+ const bundlePath = path.join(tmpDir, `${manifest.name}-${manifest.version}.tar.gz`);
578
+ try {
579
+ spawnSync('tar', ['czf', bundlePath, '-C', path.dirname(resolvedPath), path.basename(resolvedPath)], {
580
+ stdio: 'pipe',
581
+ });
582
+ } catch (err) {
583
+ console.error(chalk.red(`Failed to create bundle: ${err.message}`));
584
+ fs.rmSync(tmpDir, { recursive: true, force: true });
585
+ process.exitCode = 1;
586
+ return;
587
+ }
588
+
589
+ // Compute SHA-256 hash of the bundle
590
+ const bundleData = fs.readFileSync(bundlePath);
591
+ const bundleHash = crypto.createHash('sha256').update(bundleData).digest('hex');
592
+
593
+ // Sign the hash with Ed25519
594
+ const secretKey = Buffer.from(identity.privateKey, 'hex');
595
+ const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
596
+ sodium.crypto_sign_detached(sig, Buffer.from(bundleHash), secretKey);
597
+ const signature = sig.toString('hex');
598
+
599
+ // Read README if present
600
+ let readme = '';
601
+ for (const name of ['README.md', 'readme.md', 'README', 'readme.txt']) {
602
+ const readmePath = path.join(resolvedPath, name);
603
+ if (fs.existsSync(readmePath)) {
604
+ readme = fs.readFileSync(readmePath, 'utf-8');
605
+ break;
606
+ }
607
+ }
608
+
609
+ console.log(chalk.cyan(`Publishing ${manifest.name} v${manifest.version}...`));
610
+ console.log(chalk.gray(` Bundle: ${(bundleData.length / 1024).toFixed(1)} KB`));
611
+ console.log(chalk.gray(` Hash: ${bundleHash.slice(0, 16)}...`));
612
+
613
+ try {
614
+ const baseUrl = getPrimaryServer();
615
+ const form = new FormData();
616
+ form.append('bundle', new Blob([bundleData]), path.basename(bundlePath));
617
+ form.append('name', manifest.name);
618
+ form.append('version', manifest.version);
619
+ form.append('description', manifest.description || '');
620
+ form.append('author', manifest.author || '');
621
+ form.append('permissions', JSON.stringify(manifest.permissions || []));
622
+ form.append('hooks', JSON.stringify(manifest.hooks || []));
623
+ form.append('pro', String(manifest.pro || false));
624
+ form.append('license', manifest.license || 'MIT');
625
+ form.append('readme', readme);
626
+ form.append('bundleHash', bundleHash);
627
+ form.append('signerPublicKey', manifest.signerPublicKey || identity.publicKey);
628
+ form.append('signature', signature);
629
+ if (manifest.pricing) form.append('pricing', manifest.pricing);
630
+ if (manifest.price != null) form.append('price', String(manifest.price));
631
+
632
+ const res = await fetch(`${baseUrl}/api/v1/marketplace/publish`, {
633
+ method: 'POST',
634
+ headers: { 'x-api-key': apiKey },
635
+ body: form,
636
+ signal: AbortSignal.timeout(30000),
637
+ });
638
+
639
+ const result = await res.json();
640
+ if (res.ok && result?.success) {
641
+ console.log(chalk.green(`\u2714 Published ${manifest.name} v${manifest.version}`));
642
+ if (result.url) console.log(chalk.gray(` ${result.url}`));
643
+ } else {
644
+ console.error(chalk.red(`Publish failed: ${result?.error || 'Unknown error'}`));
645
+ process.exitCode = 1;
646
+ }
647
+ } catch (err) {
648
+ console.error(chalk.red(`Publish failed: ${err.message}`));
649
+ process.exitCode = 1;
650
+ } finally {
651
+ fs.rmSync(tmpDir, { recursive: true, force: true });
652
+ }
653
+ });
654
+
655
+ cmd
656
+ .command('buy <name>')
657
+ .description('purchase a paid extension from the marketplace')
658
+ .action(async (name) => {
659
+ const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
660
+ const { getIdentity } = await import('../core/identity.js');
661
+ try {
662
+ const searchRes = await fetchFirst(`/api/v1/marketplace?q=${encodeURIComponent(name)}&limit=1`);
663
+ if (!searchRes) {
664
+ console.error(chalk.red('Failed to connect to marketplace.'));
665
+ process.exitCode = 1;
666
+ return;
667
+ }
668
+ const data = await searchRes.json();
669
+ const ext = data?.results?.find(e => e.name === name);
670
+ if (!ext) {
671
+ console.error(chalk.red(`Extension "${name}" not found in marketplace.`));
672
+ process.exitCode = 1;
673
+ return;
674
+ }
675
+
676
+ if (!ext.price || ext.price === 0) {
677
+ console.log(chalk.gray(`"${name}" is free. Use: pal ext install-remote ${name}`));
678
+ return;
679
+ }
680
+
681
+ console.log('');
682
+ console.log(` ${chalk.white(ext.name)} v${ext.version}`);
683
+ console.log(` ${chalk.gray(ext.description || '')}`);
684
+ console.log(` Price: ${chalk.yellow('$' + ext.price.toFixed(2))}`);
685
+ console.log('');
686
+
687
+ const ok = await confirm(chalk.white(` Purchase ${ext.name} for $${ext.price.toFixed(2)}? (y/N) `));
688
+ if (!ok) {
689
+ console.log(chalk.gray(' Cancelled.'));
690
+ return;
691
+ }
692
+
693
+ const identity = await getIdentity();
694
+ if (!identity?.publicKey) {
695
+ console.error(chalk.red('No identity. Run pal init first.'));
696
+ process.exitCode = 1;
697
+ return;
698
+ }
699
+
700
+ const apiKey = await getApiKey();
701
+ if (!apiKey) {
702
+ console.error(chalk.red('No API key. Create one: pal api-keys create'));
703
+ process.exitCode = 1;
704
+ return;
705
+ }
706
+
707
+ const baseUrl = getPrimaryServer();
708
+ const checkoutRes = await fetch(`${baseUrl}/api/v1/marketplace/${ext.id}/checkout`, {
709
+ method: 'POST',
710
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
711
+ signal: AbortSignal.timeout(10000),
712
+ });
713
+ if (!checkoutRes.ok) {
714
+ console.error(chalk.red('Failed to get checkout URL.'));
715
+ process.exitCode = 1;
716
+ return;
717
+ }
718
+ const checkout = await checkoutRes.json();
719
+ if (checkout.checkoutUrl) {
720
+ console.log(chalk.cyan(`\n Opening checkout: ${checkout.checkoutUrl}\n`));
721
+ try {
722
+ const parsed = new URL(checkout.checkoutUrl);
723
+ if (parsed.protocol === 'https:') {
724
+ const { exec } = await import('child_process');
725
+ exec(`start "" "${parsed.href}"`);
726
+ }
727
+ } catch {}
728
+ }
729
+
730
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
731
+ const token = await new Promise(resolve => {
732
+ rl.question(chalk.white(' Paste your purchase token: '), answer => {
733
+ rl.close();
734
+ resolve(answer.trim());
735
+ });
736
+ });
737
+
738
+ if (!token) {
739
+ console.log(chalk.gray(' No token provided. Cancelled.'));
740
+ return;
741
+ }
742
+
743
+ const purchaseRes = await fetch(`${baseUrl}/api/v1/marketplace/${ext.id}/purchase`, {
744
+ method: 'POST',
745
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
746
+ body: JSON.stringify({ paymentToken: token }),
747
+ signal: AbortSignal.timeout(10000),
748
+ });
749
+ if (!purchaseRes.ok) {
750
+ const err = await purchaseRes.json().catch(() => ({}));
751
+ console.error(chalk.red(`Purchase failed: ${err.error || 'Unknown error'}`));
752
+ process.exitCode = 1;
753
+ return;
754
+ }
755
+ const purchaseData = await purchaseRes.json();
756
+ if (purchaseData.success && purchaseData.bundleUrl) {
757
+ console.log(chalk.cyan(`Installing ${name}...`));
758
+ const { installExtension } = await import('../core/extensions.js');
759
+ const result = await installExtension(purchaseData.bundleUrl);
760
+ console.log(chalk.green(`\u2714 Purchased and installed "${result.name}" v${result.version || ext.version}`));
761
+ } else if (purchaseData.success) {
762
+ console.log(chalk.green(`\u2714 Purchase confirmed. Install: pal ext install-remote ${name}`));
763
+ } else {
764
+ console.error(chalk.red(`Purchase failed: ${purchaseData.error || 'Unknown error'}`));
765
+ process.exitCode = 1;
766
+ }
767
+ } catch (err) {
768
+ console.error(chalk.red(`Buy failed: ${err.message}`));
769
+ process.exitCode = 1;
770
+ }
771
+ });
772
+
773
+ cmd
774
+ .command('earnings')
775
+ .description('show developer earnings from published extensions')
776
+ .action(async () => {
777
+ const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
778
+ const { getIdentity } = await import('../core/identity.js');
779
+ try {
780
+ const identity = await getIdentity();
781
+ if (!identity?.publicKey) {
782
+ console.error(chalk.red('No identity. Run pal init first.'));
783
+ process.exitCode = 1;
784
+ return;
785
+ }
786
+ const apiKey = await getApiKey();
787
+ if (!apiKey) {
788
+ console.error(chalk.red('No API key. Create one: pal api-keys create'));
789
+ process.exitCode = 1;
790
+ return;
791
+ }
792
+ const baseUrl = getPrimaryServer();
793
+ const res = await fetch(`${baseUrl}/api/v1/marketplace/earnings`, {
794
+ headers: { 'x-api-key': apiKey },
795
+ signal: AbortSignal.timeout(10000),
796
+ });
797
+ if (!res.ok) {
798
+ console.error(chalk.red('Failed to fetch earnings.'));
799
+ process.exitCode = 1;
800
+ return;
801
+ }
802
+ const data = await res.json();
803
+ console.log('');
804
+ console.log(chalk.cyan.bold('Developer Earnings'));
805
+ console.log(chalk.gray('\u2500'.repeat(40)));
806
+ console.log(` Total Revenue: ${chalk.white('$' + (data.totalRevenue || 0).toFixed(2))}`);
807
+ console.log(` Available: ${chalk.green('$' + (data.available || 0).toFixed(2))}`);
808
+ console.log(` Paid Out: ${chalk.gray('$' + (data.paidOut || 0).toFixed(2))}`);
809
+
810
+ if (data.recentSales?.length) {
811
+ console.log('');
812
+ console.log(chalk.cyan('Recent Sales:'));
813
+ for (const sale of data.recentSales) {
814
+ console.log(` ${chalk.gray(new Date(sale.date).toLocaleDateString())} ${chalk.white(sale.extName)} $${sale.amount.toFixed(2)}`);
815
+ }
816
+ }
817
+
818
+ if (data.payouts?.length) {
819
+ console.log('');
820
+ console.log(chalk.cyan('Payout History:'));
821
+ for (const p of data.payouts) {
822
+ const statusColor = p.status === 'paid' ? chalk.green : p.status === 'pending' ? chalk.yellow : chalk.gray;
823
+ console.log(` ${chalk.gray(new Date(p.date).toLocaleDateString())} $${p.amount.toFixed(2)} ${statusColor(p.status)} ${chalk.gray(p.email)}`);
824
+ }
825
+ }
826
+ console.log('');
827
+ } catch (err) {
828
+ console.error(chalk.red(`Earnings failed: ${err.message}`));
829
+ process.exitCode = 1;
830
+ }
831
+ });
832
+
833
+ cmd
834
+ .command('my-extensions')
835
+ .description('list your published extensions with stats')
836
+ .action(async () => {
837
+ const { fetchFirst, getPrimaryServer } = await import('../core/discoveryClient.js');
838
+ const { getIdentity } = await import('../core/identity.js');
839
+ try {
840
+ const identity = await getIdentity();
841
+ if (!identity?.publicKey) {
842
+ console.error(chalk.red('No identity. Run pal init first.'));
843
+ process.exitCode = 1;
844
+ return;
845
+ }
846
+ const apiKey = await getApiKey();
847
+ if (!apiKey) {
848
+ console.error(chalk.red('No API key. Create one: pal api-keys create'));
849
+ process.exitCode = 1;
850
+ return;
851
+ }
852
+ const baseUrl = getPrimaryServer();
853
+ const res = await fetch(`${baseUrl}/api/v1/marketplace/my-extensions`, {
854
+ headers: { 'x-api-key': apiKey },
855
+ signal: AbortSignal.timeout(10000),
856
+ });
857
+ if (!res.ok) {
858
+ console.error(chalk.red('Failed to fetch extensions.'));
859
+ process.exitCode = 1;
860
+ return;
861
+ }
862
+ const data = await res.json();
863
+ if (!data.extensions?.length) {
864
+ console.log(chalk.gray('No published extensions.'));
865
+ console.log(chalk.gray(' pal ext publish <path>'));
866
+ return;
867
+ }
868
+ console.log('');
869
+ console.log(chalk.cyan('My Published Extensions:'));
870
+ console.log(chalk.gray('\u2500'.repeat(60)));
871
+ for (const ext of data.extensions) {
872
+ const price = ext.price ? chalk.yellow(`$${ext.price.toFixed(2)}`) : chalk.green('Free');
873
+ const stars = '\u2605'.repeat(Math.round(ext.rating || 0)) + '\u2606'.repeat(5 - Math.round(ext.rating || 0));
874
+ console.log(` ${chalk.white(ext.name)} v${ext.version} ${price}`);
875
+ console.log(` ${chalk.yellow(stars)} (${ext.reviewCount || 0}) \u00b7 ${chalk.cyan(ext.downloads || 0)} downloads \u00b7 ${chalk.green(ext.purchases || 0)} purchases`);
876
+ console.log(` Revenue: ${chalk.green('$' + (ext.revenue || 0).toFixed(2))}`);
877
+ console.log('');
878
+ }
879
+ } catch (err) {
880
+ console.error(chalk.red(`Failed: ${err.message}`));
881
+ process.exitCode = 1;
882
+ }
883
+ });
884
+
885
+ cmd
886
+ .command('create <name>')
887
+ .description('scaffold a new extension')
888
+ .action(async (extName) => {
889
+ const dir = path.resolve(extName);
890
+ const baseName = path.basename(dir);
891
+ if (fs.existsSync(dir)) {
892
+ console.log(chalk.red(`Directory "${extName}" already exists.`));
893
+ process.exitCode = 1;
894
+ return;
895
+ }
896
+
897
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
898
+ fs.mkdirSync(path.join(dir, 'test'), { recursive: true });
899
+
900
+ const manifest = {
901
+ name: baseName,
902
+ version: '1.0.0',
903
+ description: `${baseName} extension for Palexplorer`,
904
+ author: '',
905
+ license: 'MIT',
906
+ main: 'index.js',
907
+ hooks: ['on:app:ready'],
908
+ permissions: ['config:read'],
909
+ config: {
910
+ enabled: { type: 'boolean', default: true, description: 'Enable this extension' },
911
+ },
912
+ help: {
913
+ summary: `${baseName} brief description of what this extension does.`,
914
+ usage: `Enable: pal ext config ${baseName} enabled true`,
915
+ examples: [
916
+ `pal ext help ${baseName}`,
917
+ `pal ext config ${baseName} enabled true`,
918
+ `pal ext info ${baseName}`,
919
+ ],
920
+ configReference: {
921
+ enabled: 'Enable or disable this extension (true/false)',
922
+ },
923
+ links: {},
924
+ },
925
+ pro: false,
926
+ minAppVersion: '0.5.0',
927
+ };
928
+
929
+ fs.writeFileSync(
930
+ path.join(dir, 'extension.json'),
931
+ JSON.stringify(manifest, null, 2) + '\n'
932
+ );
933
+
934
+ fs.writeFileSync(path.join(dir, 'index.js'), `let ctx = null;
935
+
936
+ export function activate(context) {
937
+ ctx = context;
938
+
939
+ context.hooks.on('on:app:ready', async () => {
940
+ context.logger.info('${baseName} ready');
941
+ });
942
+ }
943
+
944
+ export function deactivate() {
945
+ ctx = null;
946
+ }
947
+ `);
948
+
949
+ fs.writeFileSync(path.join(dir, 'test', `${baseName}.test.js`), `import { describe, it, beforeEach, mock } from 'node:test';
950
+ import assert from 'node:assert/strict';
951
+
952
+ function createMockContext(overrides = {}) {
953
+ return {
954
+ hooks: { on: mock.fn() },
955
+ config: { get: mock.fn(), set: mock.fn() },
956
+ store: { get: mock.fn(), set: mock.fn(), delete: mock.fn() },
957
+ logger: { info: mock.fn(), warn: mock.fn(), error: mock.fn() },
958
+ app: { version: '0.5.0', platform: 'linux', dataDir: '/tmp/test' },
959
+ ...overrides,
960
+ };
961
+ }
962
+
963
+ describe('${baseName}', () => {
964
+ let ext;
965
+ let ctx;
966
+
967
+ beforeEach(async () => {
968
+ ext = await import('../index.js');
969
+ ctx = createMockContext();
970
+ });
971
+
972
+ it('should activate and register hooks', () => {
973
+ ext.activate(ctx);
974
+ assert.ok(ctx.hooks.on.mock.calls.length > 0);
975
+ });
976
+
977
+ it('should deactivate cleanly', () => {
978
+ ext.activate(ctx);
979
+ ext.deactivate();
980
+ });
981
+ });
982
+ `);
983
+
984
+ fs.writeFileSync(path.join(dir, 'README.md'), `# ${baseName}
985
+
986
+ Brief description of what this extension does.
987
+
988
+ ## Configuration
989
+
990
+ | Key | Type | Default | Description |
991
+ |-----|------|---------|-------------|
992
+ | \`enabled\` | boolean | \`true\` | Enable this extension |
993
+
994
+ \`\`\`bash
995
+ pal ext config ${baseName} enabled true
996
+ \`\`\`
997
+ `);
998
+
999
+ fs.writeFileSync(path.join(dir, 'docs', 'PLAN.md'), `# ${baseName} — Plan
1000
+
1001
+ ## Goal
1002
+
1003
+ What problem does this extension solve?
1004
+
1005
+ ## Design
1006
+
1007
+ - How it hooks into core
1008
+ - Key decisions and trade-offs
1009
+ `);
1010
+
1011
+ fs.writeFileSync(path.join(dir, 'docs', 'MONETIZATION.md'), `# ${baseName} — Monetization
1012
+
1013
+ ## Tier
1014
+
1015
+ - [ ] Free
1016
+ - [ ] Pro
1017
+ - [ ] Enterprise
1018
+
1019
+ ## Rationale
1020
+
1021
+ Why this tier?
1022
+ `);
1023
+
1024
+ console.log(chalk.green(`✔ Scaffolded extension at ./${baseName}/`));
1025
+ console.log(chalk.gray(' Files created:'));
1026
+ console.log(chalk.gray(' extension.json'));
1027
+ console.log(chalk.gray(' index.js'));
1028
+ console.log(chalk.gray(' README.md'));
1029
+ console.log(chalk.gray(' docs/PLAN.md'));
1030
+ console.log(chalk.gray(' docs/MONETIZATION.md'));
1031
+ console.log(chalk.gray(` test/${baseName}.test.js`));
1032
+ console.log('');
1033
+ console.log(chalk.cyan(' Install it:'));
1034
+ console.log(chalk.white(` pal ext install ./${baseName}`));
1035
+ });
1036
+ }
1037
+
1038
+ async function listExtensions() {
1039
+ const { getInstalledExtensions } = await import('../core/extensions.js');
1040
+ const extensions = getInstalledExtensions();
1041
+ if (extensions.length === 0) {
1042
+ console.log(chalk.gray('No extensions installed.'));
1043
+ console.log(chalk.gray(' pal ext install <path|git-url>'));
1044
+ console.log(chalk.gray(' pal ext create <name>'));
1045
+ return;
1046
+ }
1047
+
1048
+ console.log('');
1049
+ console.log(chalk.cyan('Extensions:'));
1050
+ for (const ext of extensions) {
1051
+ const name = ext.bundled ? chalk.blue(`@palexplorer/${ext.name}`) : chalk.white(ext.name);
1052
+ const status = ext.enabled ? chalk.green('●') : chalk.red('○');
1053
+ const version = chalk.gray(`v${ext.version}`);
1054
+ const tier = ext.tier || (ext.pro ? 'pro' : 'free');
1055
+ const tierBadge = tier === 'enterprise' ? chalk.magenta(' [Enterprise]')
1056
+ : tier === 'pro' ? chalk.yellow(' [Pro]') : '';
1057
+ console.log(` ${status} ${name} ${version}${tierBadge}`);
1058
+ if (ext.description) console.log(` ${chalk.gray(ext.description)}`);
1059
+ }
1060
+ }