pal-explorer-cli 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,1082 @@
1
+ import { EventEmitter } from 'events';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import sodium from 'sodium-native';
7
+ import config from '../utils/config.js';
8
+ import { analyzeFile, analyzeExtension, BLOCKED_MODULES as ANALYZER_BLOCKED } from './extensionAnalyzer.js';
9
+ import { SandboxedExtension } from './extensionSandbox.js';
10
+ import appLogger from '../utils/logger.js';
11
+ import { getActivePlan } from './billing.js';
12
+
13
+ const EXTENSIONS_DIR = path.join(
14
+ process.env.PAL_DATA_DIR || path.join(process.env.HOME || process.env.USERPROFILE, '.palexplorer'),
15
+ 'extensions'
16
+ );
17
+
18
+ const BUNDLED_DIR = path.join(EXTENSIONS_DIR, '@palexplorer');
19
+
20
+ const CORE_EXTENSIONS = new Set([
21
+ 'analytics',
22
+ 'discovery',
23
+ 'explorer-integration',
24
+ 'vfs',
25
+ ]);
26
+
27
+ // Ed25519 public key for verifying extension signatures (Palexplorer team key)
28
+ const TEAM_PUBLIC_KEY = 'ba71d876238e24b1b230c567a6b7339abdf55030e98580a5a883b97ee0a3f084';
29
+ const TRUSTED_SIGNING_KEY = process.env.PAL_EXT_SIGNING_KEY || TEAM_PUBLIC_KEY;
30
+
31
+ const BLOCKED_MODULES = ANALYZER_BLOCKED;
32
+
33
+ const VALID_PERMISSIONS = new Set([
34
+ 'fs:read', 'fs:write', 'fs:delete',
35
+ 'net:http', 'net:ws', 'net:listen',
36
+ 'config:read', 'config:write',
37
+ 'identity:read', 'identity:write',
38
+ 'shares:read', 'shares:write',
39
+ 'transfers:read',
40
+ 'peers:read',
41
+ 'messages:send', 'messages:read',
42
+ 'schedule:write',
43
+ 'clipboard', 'notifications',
44
+ 'audit:read', 'audit:write',
45
+ ]);
46
+
47
+ const HIGH_RISK_PERMISSIONS = new Set([
48
+ 'fs:write', 'fs:delete', 'net:http', 'net:ws', 'messages:send', 'shares:write',
49
+ ]);
50
+
51
+ // Whether to require signatures on community extensions (default: true in production)
52
+ const REQUIRE_SIGNATURE = config.get('extensionRequireSignature') ?? (process.env.NODE_ENV === 'production');
53
+
54
+ const TIER_RANK = { free: 0, pro: 1, enterprise: 2 };
55
+
56
+ function checkExtensionTier(manifest) {
57
+ const tier = manifest.tier || (manifest.pro ? 'pro' : 'free');
58
+ if (tier === 'free') return { allowed: true, tier, planTier: 'free' };
59
+
60
+ let plan;
61
+ try { plan = getActivePlan(); } catch { plan = { key: 'free' }; }
62
+
63
+ const planTier = plan.key === 'free' ? 'free'
64
+ : plan.key.startsWith('enterprise') ? 'enterprise' : 'pro';
65
+
66
+ if (TIER_RANK[planTier] >= TIER_RANK[tier]) {
67
+ return { allowed: true, tier, planTier };
68
+ }
69
+
70
+ return { allowed: false, tier, planTier };
71
+ }
72
+
73
+ const OFFLINE_GRACE_DAYS = 7;
74
+
75
+ async function verifyExtensionLicense(manifest, extPath) {
76
+ const tier = manifest.tier || (manifest.pro ? 'pro' : 'free');
77
+ if (tier === 'free' || manifest.bundled) return true;
78
+
79
+ const machineId = crypto.createHash('sha256')
80
+ .update(os.hostname() + os.userInfo().username)
81
+ .digest('hex');
82
+
83
+ const name = manifest.name;
84
+ const cached = config.get(`ext_license.${name}`);
85
+ if (cached && cached.token && cached.expiresAt) {
86
+ if (Date.now() < cached.expiresAt) return true;
87
+ }
88
+
89
+ const discoveryServer = config.get('discoveryServer') || process.env.PAL_DISCOVERY_SERVER;
90
+ if (!discoveryServer) {
91
+ console.warn(`[extensions] No discovery server configured for license verification of ${name}`);
92
+ return _checkOfflineGrace(name);
93
+ }
94
+
95
+ const apiKey = config.get('apiKey');
96
+
97
+ try {
98
+ const res = await fetch(`${discoveryServer}/api/v1/extensions/verify`, {
99
+ method: 'POST',
100
+ headers: {
101
+ 'Content-Type': 'application/json',
102
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
103
+ },
104
+ body: JSON.stringify({ extensionId: name, machineId }),
105
+ signal: AbortSignal.timeout(10000),
106
+ });
107
+
108
+ if (res.ok) {
109
+ const data = await res.json();
110
+ config.set(`ext_license.${name}`, {
111
+ token: data.token || true,
112
+ expiresAt: data.expiresAt || (Date.now() + 24 * 60 * 60 * 1000),
113
+ });
114
+ config.set(`ext_license_last.${name}`, Date.now());
115
+ return true;
116
+ }
117
+
118
+ if (res.status === 403) {
119
+ console.warn(`[extensions] License denied for ${name} (403)`);
120
+ return _checkOfflineGrace(name);
121
+ }
122
+
123
+ console.warn(`[extensions] License server returned ${res.status} for ${name}`);
124
+ return _checkOfflineGrace(name);
125
+ } catch (err) {
126
+ console.warn(`[extensions] License verification network error for ${name}: ${err.message}`);
127
+ return _checkOfflineGrace(name);
128
+ }
129
+ }
130
+
131
+ function _checkOfflineGrace(name) {
132
+ const lastVerified = config.get(`ext_license_last.${name}`);
133
+ if (lastVerified && (Date.now() - lastVerified) < OFFLINE_GRACE_DAYS * 24 * 60 * 60 * 1000) {
134
+ console.warn(`[extensions] ${name} running in offline grace period`);
135
+ return true;
136
+ }
137
+ console.error(`[extensions] ${name} license expired. Purchase or renew at https://palexplorer.com/pro`);
138
+ return false;
139
+ }
140
+
141
+ class ExtensionHooks extends EventEmitter {
142
+ constructor() {
143
+ super();
144
+ this.setMaxListeners(100);
145
+ this._sandboxedExtensions = [];
146
+ }
147
+
148
+ addSandboxed(sandbox) {
149
+ this._sandboxedExtensions.push(sandbox);
150
+ }
151
+
152
+ removeSandboxed(sandbox) {
153
+ this._sandboxedExtensions = this._sandboxedExtensions.filter(s => s !== sandbox);
154
+ }
155
+
156
+ async emit(event, payload) {
157
+ // First run in-process listeners (bundled extensions)
158
+ const listeners = this.listeners(event);
159
+ for (const fn of listeners) {
160
+ try {
161
+ const result = await fn(payload);
162
+ if (result && result.block) {
163
+ return { blocked: true, reason: result.reason || 'Blocked by extension' };
164
+ }
165
+ } catch (err) {
166
+ console.error(`[extensions] Hook error on ${event}:`, err.message);
167
+ }
168
+ }
169
+
170
+ // Then run sandboxed extension hooks
171
+ for (const sandbox of this._sandboxedExtensions) {
172
+ try {
173
+ const result = await sandbox.callHook(event, payload);
174
+ if (result && result.blocked) {
175
+ return { blocked: true, reason: result.reason || 'Blocked by sandboxed extension' };
176
+ }
177
+ } catch (err) {
178
+ console.error(`[extensions] Sandbox hook error on ${event}:`, err.message);
179
+ }
180
+ }
181
+
182
+ return { blocked: false };
183
+ }
184
+ }
185
+
186
+ const hooks = new ExtensionHooks();
187
+ const loadedExtensions = new Map();
188
+
189
+ function validateManifest(manifest) {
190
+ if (!manifest.name || typeof manifest.name !== 'string') return 'Missing or invalid name';
191
+ if (manifest.name.length > 50) return 'Name exceeds 50 characters';
192
+ if (!manifest.version) return 'Missing version';
193
+ if (!/^\d+\.\d+\.\d+/.test(manifest.version)) return 'Version must be valid semver (e.g. 1.0.0)';
194
+ if (!manifest.main) return 'Missing main entry point';
195
+ if (typeof manifest.main !== 'string' || !manifest.main.endsWith('.js')) return 'Main entry point must be a .js file';
196
+ if (/\.\.[\\/]|^[/\\~]/.test(manifest.main)) return 'Main entry point contains path traversal';
197
+ if (manifest.description && manifest.description.length > 500) return 'Description exceeds 500 characters';
198
+ if (manifest.permissions) {
199
+ if (manifest.permissions.length > 10) return 'Too many permissions (max 10)';
200
+ for (const p of manifest.permissions) {
201
+ if (!p.startsWith('exec:') && !VALID_PERMISSIONS.has(p)) {
202
+ return `Invalid permission: ${p}`;
203
+ }
204
+ }
205
+ }
206
+ if (manifest.hooks && manifest.hooks.length > 20) return 'Too many hooks (max 20)';
207
+ return null;
208
+ }
209
+
210
+ function verifySignature(extPath, manifest) {
211
+ const sigPath = path.join(extPath, 'extension.sig');
212
+ if (!fs.existsSync(sigPath)) return { signed: false, verified: false };
213
+
214
+ try {
215
+ const sig = fs.readFileSync(sigPath);
216
+ if (sig.length !== sodium.crypto_sign_BYTES) {
217
+ return { signed: true, verified: false, reason: 'Invalid signature length' };
218
+ }
219
+
220
+ const manifestData = fs.readFileSync(path.join(extPath, 'extension.json'));
221
+ const mainData = fs.readFileSync(path.join(extPath, manifest.main || 'index.js'));
222
+ const content = Buffer.concat([manifestData, mainData]);
223
+
224
+ if (TRUSTED_SIGNING_KEY) {
225
+ const pubKey = Buffer.from(TRUSTED_SIGNING_KEY, 'hex');
226
+ if (pubKey.length === sodium.crypto_sign_PUBLICKEYBYTES) {
227
+ if (sodium.crypto_sign_verify_detached(sig, content, pubKey)) {
228
+ return { signed: true, verified: true, signer: 'palexplorer' };
229
+ }
230
+ }
231
+ }
232
+
233
+ if (manifest.signerPublicKey) {
234
+ const pubKey = Buffer.from(manifest.signerPublicKey, 'hex');
235
+ if (pubKey.length === sodium.crypto_sign_PUBLICKEYBYTES) {
236
+ if (sodium.crypto_sign_verify_detached(sig, content, pubKey)) {
237
+ return { signed: true, verified: true, signer: manifest.signerPublicKey };
238
+ }
239
+ }
240
+ }
241
+
242
+ return { signed: true, verified: false, reason: 'No matching public key' };
243
+ } catch (err) {
244
+ return { signed: true, verified: false, reason: err.message };
245
+ }
246
+ }
247
+
248
+ function computeIntegrityHash(extPath, manifest) {
249
+ const hash = crypto.createHash('sha256');
250
+ const mainPath = path.join(extPath, manifest.main || 'index.js');
251
+ if (fs.existsSync(mainPath)) hash.update(fs.readFileSync(mainPath));
252
+ const manifestPath = path.join(extPath, 'extension.json');
253
+ if (fs.existsSync(manifestPath)) hash.update(fs.readFileSync(manifestPath));
254
+ return hash.digest('hex');
255
+ }
256
+
257
+ function getSecurityRisk(manifest) {
258
+ const perms = manifest.permissions || [];
259
+ const highRisk = perms.filter(p => HIGH_RISK_PERMISSIONS.has(p) || p.startsWith('exec:'));
260
+ if (highRisk.length >= 3) return 'high';
261
+ if (highRisk.length >= 1) return 'medium';
262
+ return 'low';
263
+ }
264
+
265
+ // Legacy regex-based scanning — kept for backward compat, but AST analyzer is primary
266
+ function hasBlockedImports(filePath) {
267
+ const result = analyzeFile(filePath);
268
+ if (result.blocked.length > 0) return result.blocked[0].module;
269
+ return null;
270
+ }
271
+
272
+ function scanDangerousPatterns(filePath) {
273
+ const result = analyzeFile(filePath);
274
+ return result.dangerous.map(f => f.message);
275
+ }
276
+
277
+ // Full AST analysis — returns rich structured data
278
+ function analyzeExtensionSecurity(extPath, manifest) {
279
+ return analyzeExtension(extPath, manifest);
280
+ }
281
+
282
+ function getApprovedPermissions(extName) {
283
+ return config.get(`ext_approved_perms.${extName}`) || null;
284
+ }
285
+
286
+ function setApprovedPermissions(extName, permissions) {
287
+ config.set(`ext_approved_perms.${extName}`, permissions);
288
+ }
289
+
290
+ function hasPendingPermissions(extName, manifest) {
291
+ const approved = getApprovedPermissions(extName);
292
+ if (!approved) return (manifest.permissions?.length || 0) > 0;
293
+ const requested = manifest.permissions || [];
294
+ return requested.some(p => !approved.includes(p));
295
+ }
296
+
297
+ function getPendingPermissions(extName, manifest) {
298
+ const approved = getApprovedPermissions(extName) || [];
299
+ return (manifest.permissions || []).filter(p => !approved.includes(p));
300
+ }
301
+
302
+ function getSharePaths() {
303
+ const shares = config.get('shares') || [];
304
+ return shares.map(s => s.path).filter(Boolean);
305
+ }
306
+
307
+ function buildContext(manifest) {
308
+ const perms = new Set(manifest.permissions || []);
309
+ const defaults = manifest.config || {};
310
+
311
+ function liveConfig() {
312
+ const extConfig = config.get(`ext.${manifest.name}`) || {};
313
+ for (const [key, def] of Object.entries(defaults)) {
314
+ if (extConfig[key] === undefined && def.default !== undefined) {
315
+ extConfig[key] = def.default;
316
+ }
317
+ }
318
+ return extConfig;
319
+ }
320
+
321
+ const sharePaths = getSharePaths();
322
+
323
+ function enforcePathScope(filePath) {
324
+ const resolved = path.resolve(filePath);
325
+ let real;
326
+ try { real = fs.realpathSync(resolved); } catch { real = resolved; }
327
+ const inScope = sharePaths.some(p => {
328
+ const rp = path.resolve(p);
329
+ return real === rp || real.startsWith(rp + path.sep);
330
+ });
331
+ if (!inScope) {
332
+ throw new Error(`Path access denied: ${filePath} is outside share paths`);
333
+ }
334
+ return real;
335
+ }
336
+
337
+ const ctx = {
338
+ hooks,
339
+ config: {
340
+ get(key) {
341
+ if (!perms.has('config:read')) throw new Error('Permission denied: config:read');
342
+ return liveConfig()[key];
343
+ },
344
+ set(key, value) {
345
+ if (!perms.has('config:write')) throw new Error('Permission denied: config:write');
346
+ const current = liveConfig();
347
+ current[key] = value;
348
+ config.set(`ext.${manifest.name}`, current);
349
+ },
350
+ getAll() {
351
+ if (!perms.has('config:read')) throw new Error('Permission denied: config:read');
352
+ return { ...liveConfig() };
353
+ },
354
+ },
355
+ logger: {
356
+ info: (...args) => { if (appLogger.shouldLog('info')) console.log(`[ext:${manifest.name}]`, ...args); },
357
+ warn: (...args) => console.warn(`[ext:${manifest.name}]`, ...args),
358
+ error: (...args) => console.error(`[ext:${manifest.name}]`, ...args),
359
+ },
360
+ app: {
361
+ version: getAppVersion(),
362
+ dataDir: EXTENSIONS_DIR,
363
+ appRoot: path.resolve(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'), '../../..'),
364
+ platform: process.platform,
365
+ mode: globalThis.__palMode || 'cli',
366
+ },
367
+ store: createStore(manifest.name),
368
+ };
369
+
370
+ if (perms.has('identity:read')) {
371
+ ctx.identity = {
372
+ get() {
373
+ const id = config.get('identity');
374
+ return id ? { handle: id.handle, publicKey: id.publicKey } : null;
375
+ },
376
+ };
377
+ }
378
+
379
+ if (perms.has('shares:read')) {
380
+ ctx.shares = {
381
+ list() { return (config.get('shares') || []).map(s => ({ id: s.id, path: s.path, type: s.type, visibility: s.visibility })); },
382
+ get(id) {
383
+ const s = (config.get('shares') || []).find(s => s.magnet === id || s.path === id);
384
+ return s ? { id: s.id, path: s.path, type: s.type, visibility: s.visibility } : null;
385
+ },
386
+ };
387
+ }
388
+
389
+ if (perms.has('transfers:read')) {
390
+ ctx.transfers = {
391
+ async history() {
392
+ const { default: Conf } = await import('conf');
393
+ const store = new Conf({ projectName: 'palexplorer-cli', configName: 'transfers' });
394
+ return store.get('history') || [];
395
+ },
396
+ async stats() {
397
+ const { getTransferStats } = await import('./transfers.js');
398
+ return getTransferStats();
399
+ },
400
+ };
401
+ }
402
+
403
+ if (perms.has('peers:read')) {
404
+ ctx.peers = {
405
+ list() {
406
+ const friends = config.get('friends') || [];
407
+ return friends.map(f => ({ handle: f.handle, publicKey: f.publicKey }));
408
+ },
409
+ };
410
+ }
411
+
412
+ if (perms.has('net:http')) {
413
+ const httpCallLog = [];
414
+ ctx.http = async (url, opts = {}) => {
415
+ httpCallLog.push({ url, method: opts.method || 'GET', ts: Date.now() });
416
+ // Rate limit: max 100 calls per minute
417
+ const oneMinAgo = Date.now() - 60000;
418
+ const recentCalls = httpCallLog.filter(c => c.ts > oneMinAgo);
419
+ if (recentCalls.length > 100) {
420
+ throw new Error('HTTP rate limit exceeded (100 requests/minute)');
421
+ }
422
+ ctx.logger.info(`HTTP ${opts.method || 'GET'} ${url}`);
423
+ return fetch(url, { ...opts, signal: opts.signal || AbortSignal.timeout(30000) });
424
+ };
425
+ }
426
+
427
+ if (perms.has('fs:read')) {
428
+ ctx.fs = {
429
+ ...ctx.fs,
430
+ read(filePath, encoding = 'utf8') {
431
+ const real = enforcePathScope(filePath);
432
+ return fs.readFileSync(real, encoding);
433
+ },
434
+ readdir(dirPath) {
435
+ const real = enforcePathScope(dirPath);
436
+ return fs.readdirSync(real);
437
+ },
438
+ stat(filePath) {
439
+ const real = enforcePathScope(filePath);
440
+ const st = fs.statSync(real);
441
+ return { size: st.size, isFile: st.isFile(), isDirectory: st.isDirectory(), mtime: st.mtime };
442
+ },
443
+ };
444
+ }
445
+
446
+ if (perms.has('fs:write')) {
447
+ ctx.fs = {
448
+ ...ctx.fs,
449
+ write(filePath, data) {
450
+ const real = enforcePathScope(filePath);
451
+ fs.writeFileSync(real, data);
452
+ },
453
+ mkdir(dirPath) {
454
+ const real = enforcePathScope(dirPath);
455
+ fs.mkdirSync(real, { recursive: true });
456
+ },
457
+ };
458
+ }
459
+
460
+ if (perms.has('fs:delete')) {
461
+ ctx.fs = {
462
+ ...ctx.fs,
463
+ delete(filePath) {
464
+ const real = enforcePathScope(filePath);
465
+ fs.unlinkSync(real);
466
+ },
467
+ };
468
+ }
469
+
470
+ if (perms.has('notifications')) {
471
+ ctx.notify = {
472
+ show(title, body) {
473
+ if (typeof Notification !== 'undefined') {
474
+ new Notification(title, { body });
475
+ }
476
+ },
477
+ };
478
+ }
479
+
480
+ return ctx;
481
+ }
482
+
483
+ function createStore(extName) {
484
+ const storeKey = `ext_store.${extName}`;
485
+ return {
486
+ get(key) {
487
+ const data = config.get(storeKey) || {};
488
+ return key ? data[key] : data;
489
+ },
490
+ set(key, value) {
491
+ const data = config.get(storeKey) || {};
492
+ data[key] = value;
493
+ config.set(storeKey, data);
494
+ },
495
+ delete(key) {
496
+ const data = config.get(storeKey) || {};
497
+ delete data[key];
498
+ config.set(storeKey, data);
499
+ },
500
+ };
501
+ }
502
+
503
+ function getAppVersion() {
504
+ try {
505
+ const dir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'));
506
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(dir, '../../package.json'), 'utf8'));
507
+ return pkg.version;
508
+ } catch {
509
+ return '0.0.0';
510
+ }
511
+ }
512
+
513
+ function getInstalledExtensions() {
514
+ const extensions = [];
515
+ const disabled = config.get('disabledExtensions') || [];
516
+
517
+ if (!fs.existsSync(EXTENSIONS_DIR)) return extensions;
518
+
519
+ for (const entry of fs.readdirSync(EXTENSIONS_DIR)) {
520
+ const extPath = path.join(EXTENSIONS_DIR, entry);
521
+ if (entry === '@palexplorer') {
522
+ if (!fs.statSync(extPath).isDirectory()) continue;
523
+ for (const bundled of fs.readdirSync(extPath)) {
524
+ const bundledPath = path.join(extPath, bundled);
525
+ const manifest = readManifest(bundledPath);
526
+ if (manifest) {
527
+ const fullName = `@palexplorer/${bundled}`;
528
+ const isCore = CORE_EXTENSIONS.has(bundled);
529
+ const explicitEnabled = (config.get('enabledExtensions') || []).includes(fullName);
530
+ const explicitDisabled = disabled.includes(fullName);
531
+ const manifestDefault = manifest.config?.enabled?.default === true;
532
+ extensions.push({
533
+ ...manifest,
534
+ path: bundledPath,
535
+ bundled: true,
536
+ enabled: isCore
537
+ ? !explicitDisabled
538
+ : explicitDisabled ? false : (explicitEnabled || manifestDefault),
539
+ });
540
+ }
541
+ }
542
+ } else if (!entry.startsWith('.')) {
543
+ const manifest = readManifest(extPath);
544
+ if (manifest) {
545
+ extensions.push({
546
+ ...manifest,
547
+ path: extPath,
548
+ bundled: false,
549
+ enabled: !disabled.includes(entry),
550
+ });
551
+ }
552
+ }
553
+ }
554
+
555
+ return extensions;
556
+ }
557
+
558
+ function readManifest(extPath) {
559
+ const manifestPath = path.join(extPath, 'extension.json');
560
+ if (!fs.existsSync(manifestPath)) return null;
561
+ try {
562
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
563
+ } catch {
564
+ return null;
565
+ }
566
+ }
567
+
568
+ function buildSandboxContext(manifest) {
569
+ const extConfig = config.get(`ext.${manifest.name}`) || {};
570
+ const defaults = manifest.config || {};
571
+ for (const [key, def] of Object.entries(defaults)) {
572
+ if (extConfig[key] === undefined && def.default !== undefined) {
573
+ extConfig[key] = def.default;
574
+ }
575
+ }
576
+
577
+ const sharePaths = getSharePaths();
578
+
579
+ return {
580
+ configSnapshot: { ...extConfig },
581
+ appInfo: { version: getAppVersion(), platform: process.platform, mode: globalThis.__palMode || 'cli' },
582
+ configGet(key) { return extConfig[key]; },
583
+ configSet(key, value) {
584
+ extConfig[key] = value;
585
+ config.set(`ext.${manifest.name}`, extConfig);
586
+ },
587
+ storeGet(key) {
588
+ const data = config.get(`ext_store.${manifest.name}`) || {};
589
+ return key ? data[key] : data;
590
+ },
591
+ storeSet(key, value) {
592
+ const data = config.get(`ext_store.${manifest.name}`) || {};
593
+ data[key] = value;
594
+ config.set(`ext_store.${manifest.name}`, data);
595
+ },
596
+ storeDelete(key) {
597
+ const data = config.get(`ext_store.${manifest.name}`) || {};
598
+ delete data[key];
599
+ config.set(`ext_store.${manifest.name}`, data);
600
+ },
601
+ getIdentity() {
602
+ const id = config.get('identity');
603
+ return id ? { handle: id.handle, publicKey: id.publicKey } : null;
604
+ },
605
+ listShares() {
606
+ return (config.get('shares') || []).map(s => ({ id: s.id, path: s.path, type: s.type, visibility: s.visibility }));
607
+ },
608
+ listPeers() {
609
+ return (config.get('friends') || []).map(f => ({ handle: f.handle, publicKey: f.publicKey }));
610
+ },
611
+ logHttpCall(url, method) {
612
+ console.log(`[ext:${manifest.name}] HTTP ${method} ${url}`);
613
+ },
614
+ notify(title, body) {
615
+ if (typeof Notification !== 'undefined') {
616
+ new Notification(title, { body });
617
+ }
618
+ },
619
+ };
620
+ }
621
+
622
+ async function loadExtension(extPath, manifest) {
623
+ const fullName = manifest.bundled ? `@palexplorer/${manifest.name}` : manifest.name;
624
+ if (loadedExtensions.has(fullName)) return;
625
+
626
+ const error = validateManifest(manifest);
627
+ if (error) {
628
+ console.error(`[extensions] Invalid manifest for ${fullName}: ${error}`);
629
+ return;
630
+ }
631
+
632
+ // Tier enforcement — paid extensions require matching subscription
633
+ if (!manifest.bundled) {
634
+ const tierCheck = checkExtensionTier(manifest);
635
+ if (!tierCheck.allowed) {
636
+ console.warn(`[extensions] ${fullName} requires ${tierCheck.tier} plan (current: ${tierCheck.planTier}). Upgrade at https://palexplorer.com/pro`);
637
+ return;
638
+ }
639
+
640
+ if (tierCheck.tier !== 'free') {
641
+ const licensed = await verifyExtensionLicense(manifest, extPath);
642
+ if (!licensed) {
643
+ console.warn(`[extensions] ${fullName} license verification failed. Purchase at https://palexplorer.com/pro`);
644
+ return;
645
+ }
646
+ }
647
+ }
648
+
649
+ const mainPath = path.join(extPath, manifest.main || 'index.js');
650
+ if (!fs.existsSync(mainPath)) {
651
+ console.error(`[extensions] Entry point not found: ${mainPath}`);
652
+ return;
653
+ }
654
+
655
+ // Integrity check
656
+ const storedHash = config.get(`ext_integrity.${manifest.name}`);
657
+ const currentHash = computeIntegrityHash(extPath, manifest);
658
+ if (storedHash && storedHash !== currentHash) {
659
+ console.error(`[extensions] SECURITY: ${fullName} files modified since install. Refusing to load.`);
660
+ console.error(`[extensions] Run 'pe ext remove ${manifest.name} && pe ext install ...' to reinstall.`);
661
+ return;
662
+ }
663
+
664
+ if (!manifest.bundled) {
665
+ // AST analysis — catches obfuscated dynamic imports, eval tricks, etc.
666
+ const analysis = analyzeExtension(extPath, manifest);
667
+ if (analysis.blocked.length > 0) {
668
+ const mods = [...new Set(analysis.blocked.map(b => b.module))].join(', ');
669
+ console.error(`[extensions] SECURITY: ${fullName} imports blocked modules: ${mods}. Refusing to load.`);
670
+ disableExtension(fullName);
671
+ return;
672
+ }
673
+
674
+ if (analysis.hasCritical) {
675
+ console.error(`[extensions] SECURITY: ${fullName} has critical security findings. Refusing to load.`);
676
+ for (const f of analysis.findings.filter(f => f.severity === 'critical')) {
677
+ console.error(` Line ${f.line || '?'}: ${f.message}`);
678
+ }
679
+ disableExtension(fullName);
680
+ return;
681
+ }
682
+
683
+ if (analysis.hasHigh) {
684
+ console.warn(`[extensions] WARNING: ${fullName} has high-severity findings:`);
685
+ for (const f of analysis.findings.filter(f => f.severity === 'high')) {
686
+ console.warn(` Line ${f.line || '?'}: ${f.message}`);
687
+ }
688
+ }
689
+
690
+ // Permission approval check
691
+ if (hasPendingPermissions(manifest.name, manifest)) {
692
+ const pending = getPendingPermissions(manifest.name, manifest);
693
+ console.error(`[extensions] SECURITY: ${fullName} has unapproved permissions: ${pending.join(', ')}. Refusing to load.`);
694
+ return;
695
+ }
696
+
697
+ // Signature enforcement
698
+ const sig = verifySignature(extPath, manifest);
699
+ if (REQUIRE_SIGNATURE && !sig.verified) {
700
+ console.error(`[extensions] SECURITY: ${fullName} is unsigned or has invalid signature. Refusing to load.`);
701
+ console.error(`[extensions] Set 'extensionRequireSignature' to false in config to allow unsigned extensions (not recommended).`);
702
+ return;
703
+ }
704
+ if (!sig.verified) {
705
+ console.warn(`[extensions] WARNING: ${fullName} is not signed. Loading in sandbox with restrictions.`);
706
+ }
707
+
708
+ // Community extensions run in Worker thread sandbox
709
+ try {
710
+ const sandboxCtx = buildSandboxContext(manifest);
711
+ const sandbox = new SandboxedExtension(extPath, manifest, sandboxCtx);
712
+ await sandbox.start();
713
+ hooks.addSandboxed(sandbox);
714
+ loadedExtensions.set(fullName, { manifest, sandbox, path: extPath, hash: currentHash, sandboxed: true });
715
+ console.log(`[extensions] Loaded ${fullName} (sandboxed worker)`);
716
+ } catch (err) {
717
+ console.error(`[extensions] Failed to load ${fullName} in sandbox:`, err.message);
718
+ const failKey = `ext_failures.${manifest.name}`;
719
+ const failures = (config.get(failKey) || 0) + 1;
720
+ config.set(failKey, failures);
721
+ if (failures >= 3) {
722
+ console.error(`[extensions] ${fullName} failed ${failures} times. Auto-disabling.`);
723
+ disableExtension(fullName);
724
+ }
725
+ }
726
+ return;
727
+ }
728
+
729
+ // Bundled extensions run in-process (trusted)
730
+ try {
731
+ const mod = await import(`file://${mainPath.replace(/\\/g, '/')}`);
732
+ const ctx = buildContext(manifest);
733
+
734
+ if (typeof mod.activate === 'function') {
735
+ await Promise.race([
736
+ mod.activate(ctx),
737
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Activation timeout')), 10000)),
738
+ ]);
739
+ }
740
+
741
+ loadedExtensions.set(fullName, { manifest, module: mod, ctx, path: extPath, hash: currentHash, sandboxed: false });
742
+ } catch (err) {
743
+ console.error(`[extensions] Failed to load ${fullName}:`, err.message);
744
+ const failKey = `ext_failures.${manifest.name}`;
745
+ const failures = (config.get(failKey) || 0) + 1;
746
+ config.set(failKey, failures);
747
+ if (failures >= 3) {
748
+ console.error(`[extensions] ${fullName} failed ${failures} times. Auto-disabling.`);
749
+ disableExtension(fullName);
750
+ }
751
+ }
752
+ }
753
+
754
+ async function unloadExtension(name) {
755
+ const ext = loadedExtensions.get(name);
756
+ if (!ext) return;
757
+
758
+ if (ext.sandboxed && ext.sandbox) {
759
+ hooks.removeSandboxed(ext.sandbox);
760
+ await ext.sandbox.stop();
761
+ } else if (ext.module && typeof ext.module.deactivate === 'function') {
762
+ try {
763
+ await ext.module.deactivate();
764
+ } catch (err) {
765
+ console.error(`[extensions] Error deactivating ${name}:`, err.message);
766
+ }
767
+ }
768
+
769
+ loadedExtensions.delete(name);
770
+ }
771
+
772
+ async function loadAllExtensions() {
773
+ try { deployBundledExtensions(); } catch {}
774
+ const extensions = getInstalledExtensions();
775
+ for (const ext of extensions) {
776
+ if (!ext.enabled) continue;
777
+ await loadExtension(ext.path, ext);
778
+ }
779
+ }
780
+
781
+ async function installExtension(source, { skipPrompt = false } = {}) {
782
+ fs.mkdirSync(EXTENSIONS_DIR, { recursive: true });
783
+
784
+ let sourcePath = source;
785
+
786
+ if (source.startsWith('http') || source.startsWith('git@')) {
787
+ if (source.startsWith('http:')) {
788
+ throw new Error('Insecure HTTP git URLs are not allowed. Use HTTPS.');
789
+ }
790
+ const { spawnSync } = await import('child_process');
791
+ const tmpDir = path.join(EXTENSIONS_DIR, `.tmp-${Date.now()}`);
792
+ try {
793
+ const result = spawnSync('git', ['clone', '--depth', '1', source, tmpDir], { stdio: 'pipe', timeout: 30000 });
794
+ if (result.status !== 0) throw new Error(result.stderr?.toString() || 'Git clone failed');
795
+ } catch (err) {
796
+ fs.rmSync(tmpDir, { recursive: true, force: true });
797
+ throw new Error(`Git clone failed: ${err.message}`);
798
+ }
799
+ sourcePath = tmpDir;
800
+ }
801
+
802
+ const manifest = readManifest(sourcePath);
803
+ if (!manifest) {
804
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
805
+ throw new Error('No valid extension.json found at source');
806
+ }
807
+
808
+ const error = validateManifest(manifest);
809
+ if (error) {
810
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
811
+ throw new Error(`Invalid extension: ${error}`);
812
+ }
813
+
814
+ if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(manifest.name)) {
815
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
816
+ throw new Error(`Invalid extension name "${manifest.name}". Must be lowercase alphanumeric with dots, dashes, or underscores.`);
817
+ }
818
+
819
+ if (manifest.name.startsWith('@palexplorer') || manifest.name.startsWith('palexplorer-')) {
820
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
821
+ throw new Error('Cannot install extensions using reserved @palexplorer namespace.');
822
+ }
823
+
824
+ // Tier enforcement — paid extensions require matching subscription
825
+ const tierCheck = checkExtensionTier(manifest);
826
+ if (!tierCheck.allowed) {
827
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
828
+ throw new Error(`Extension '${manifest.name}' requires a ${tierCheck.tier} subscription (current: ${tierCheck.planTier}). Upgrade at https://palexplorer.com/pro`);
829
+ }
830
+
831
+ // AST analysis (replaces old regex scanning)
832
+ const analysis = analyzeExtension(sourcePath, manifest);
833
+ if (analysis.blocked.length > 0) {
834
+ const mods = [...new Set(analysis.blocked.map(b => b.module))].join(', ');
835
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
836
+ throw new Error(`SECURITY: Extension imports blocked modules: ${mods}. Install rejected.`);
837
+ }
838
+
839
+ if (analysis.hasCritical) {
840
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
841
+ const details = analysis.findings.filter(f => f.severity === 'critical').map(f => f.message).join('; ');
842
+ throw new Error(`SECURITY: Extension has critical findings: ${details}. Install rejected.`);
843
+ }
844
+
845
+ const sig = verifySignature(sourcePath, manifest);
846
+ const risk = getSecurityRisk(manifest);
847
+
848
+ if (REQUIRE_SIGNATURE && !sig.verified) {
849
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
850
+ throw new Error('SECURITY: Extension is unsigned. Set extensionRequireSignature=false to allow (not recommended).');
851
+ }
852
+
853
+ const destPath = path.join(EXTENSIONS_DIR, manifest.name);
854
+ if (fs.existsSync(destPath)) {
855
+ if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
856
+ throw new Error(`Extension "${manifest.name}" is already installed`);
857
+ }
858
+
859
+ fs.cpSync(sourcePath, destPath, { recursive: true });
860
+
861
+ const hash = computeIntegrityHash(destPath, manifest);
862
+ config.set(`ext_integrity.${manifest.name}`, hash);
863
+ config.delete(`ext_failures.${manifest.name}`);
864
+
865
+ if (manifest.permissions?.length > 0) {
866
+ setApprovedPermissions(manifest.name, manifest.permissions);
867
+ }
868
+
869
+ if (sourcePath !== source && sourcePath.includes('.tmp-')) {
870
+ fs.rmSync(sourcePath, { recursive: true, force: true });
871
+ }
872
+
873
+ return {
874
+ ...manifest,
875
+ signature: sig,
876
+ risk,
877
+ analysis: {
878
+ dangerous: analysis.dangerous.map(f => f.message),
879
+ summary: analysis.summary,
880
+ },
881
+ };
882
+ }
883
+
884
+ function removeExtension(name) {
885
+ const extPath = path.join(EXTENSIONS_DIR, name);
886
+ if (!fs.existsSync(extPath)) throw new Error(`Extension "${name}" not found`);
887
+
888
+ const manifest = readManifest(extPath);
889
+ if (!manifest) throw new Error(`Invalid extension at ${extPath}`);
890
+
891
+ unloadExtension(name);
892
+
893
+ fs.rmSync(extPath, { recursive: true, force: true });
894
+
895
+ config.delete(`ext.${name}`);
896
+ config.delete(`ext_store.${name}`);
897
+ config.delete(`ext_approved_perms.${name}`);
898
+ config.delete(`ext_integrity.${name}`);
899
+ config.delete(`ext_failures.${name}`);
900
+ }
901
+
902
+ function enableExtension(name) {
903
+ const full = name.includes('/') ? name : `@palexplorer/${name}`;
904
+ const short = name.replace('@palexplorer/', '');
905
+
906
+ const disabled = config.get('disabledExtensions') || [];
907
+ // Remove both forms from disabled list
908
+ const newDisabled = disabled.filter(d => d !== name && d !== full && d !== short);
909
+ if (newDisabled.length !== disabled.length) config.set('disabledExtensions', newDisabled);
910
+
911
+ if (!CORE_EXTENSIONS.has(short)) {
912
+ const enabled = config.get('enabledExtensions') || [];
913
+ if (!enabled.includes(full)) {
914
+ enabled.push(full);
915
+ config.set('enabledExtensions', enabled);
916
+ }
917
+ }
918
+ }
919
+
920
+ function disableExtension(name) {
921
+ const full = name.includes('/') ? name : `@palexplorer/${name}`;
922
+ const short = name.replace('@palexplorer/', '');
923
+
924
+ const disabled = config.get('disabledExtensions') || [];
925
+ if (!disabled.includes(full)) {
926
+ disabled.push(full);
927
+ config.set('disabledExtensions', disabled);
928
+ }
929
+
930
+ if (!CORE_EXTENSIONS.has(short)) {
931
+ const enabled = config.get('enabledExtensions') || [];
932
+ const newEnabled = enabled.filter(e => e !== name && e !== full && e !== short);
933
+ if (newEnabled.length !== enabled.length) config.set('enabledExtensions', newEnabled);
934
+ }
935
+ unloadExtension(name);
936
+ }
937
+
938
+ function getExtensionConfig(name) {
939
+ return config.get(`ext.${name}`) || {};
940
+ }
941
+
942
+ function setExtensionConfig(name, key, value) {
943
+ const extConfig = config.get(`ext.${name}`) || {};
944
+ extConfig[key] = value;
945
+ config.set(`ext.${name}`, extConfig);
946
+ hooks.emit('on:config:change', { key: `ext.${name}`, value: extConfig }).catch(() => {});
947
+ }
948
+
949
+ function ensureBundledDir() {
950
+ fs.mkdirSync(BUNDLED_DIR, { recursive: true });
951
+ }
952
+
953
+ function deployBundledExtensions() {
954
+ const appDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'));
955
+ const srcBundled = path.resolve(appDir, '../../extensions/@palexplorer');
956
+ if (!fs.existsSync(srcBundled)) return;
957
+
958
+ ensureBundledDir();
959
+
960
+ const srcNames = new Set();
961
+ for (const name of fs.readdirSync(srcBundled)) {
962
+ const srcPath = path.join(srcBundled, name);
963
+ if (!fs.statSync(srcPath).isDirectory()) continue;
964
+ const manifest = readManifest(srcPath);
965
+ if (!manifest) continue;
966
+ srcNames.add(name);
967
+
968
+ const destPath = path.join(BUNDLED_DIR, name);
969
+ const destManifest = readManifest(destPath);
970
+
971
+ // Deploy if not present or if source version is newer
972
+ if (!destManifest || semverGt(manifest.version, destManifest.version)) {
973
+ fs.cpSync(srcPath, destPath, { recursive: true });
974
+ }
975
+ }
976
+
977
+ // Remove bundled extensions that are no longer in source
978
+ if (fs.existsSync(BUNDLED_DIR)) {
979
+ for (const name of fs.readdirSync(BUNDLED_DIR)) {
980
+ if (name.startsWith('.')) continue;
981
+ if (!srcNames.has(name)) {
982
+ const destPath = path.join(BUNDLED_DIR, name);
983
+ if (fs.statSync(destPath).isDirectory()) {
984
+ fs.rmSync(destPath, { recursive: true, force: true });
985
+ }
986
+ }
987
+ }
988
+ }
989
+ }
990
+
991
+ function semverGt(a, b) {
992
+ const pa = (a || '0.0.0').split('.').map(Number);
993
+ const pb = (b || '0.0.0').split('.').map(Number);
994
+ for (let i = 0; i < 3; i++) {
995
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
996
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
997
+ }
998
+ return false;
999
+ }
1000
+
1001
+ function getExtension(name) {
1002
+ return loadedExtensions.get(name);
1003
+ }
1004
+
1005
+ function getContributedPages() {
1006
+ const extensions = getInstalledExtensions();
1007
+ const pages = [];
1008
+ for (const ext of extensions) {
1009
+ if (!ext.enabled) continue;
1010
+ if (!ext.contributes?.pages) continue;
1011
+ for (const page of ext.contributes.pages) {
1012
+ pages.push({
1013
+ id: page.id,
1014
+ label: page.label,
1015
+ icon: page.icon,
1016
+ section: page.section,
1017
+ minLevel: page.minLevel || null,
1018
+ extension: ext.name,
1019
+ });
1020
+ }
1021
+ }
1022
+ return pages;
1023
+ }
1024
+
1025
+ function ensureDefaultExtensions() {
1026
+ if (!fs.existsSync(BUNDLED_DIR)) return;
1027
+ const enabled = config.get('enabledExtensions') || [];
1028
+ const disabled = config.get('disabledExtensions') || [];
1029
+ let changed = false;
1030
+ for (const bundled of fs.readdirSync(BUNDLED_DIR)) {
1031
+ const manifest = readManifest(path.join(BUNDLED_DIR, bundled));
1032
+ if (!manifest) continue;
1033
+ const full = `@palexplorer/${bundled}`;
1034
+ if (manifest.config?.enabled?.default === true && !CORE_EXTENSIONS.has(bundled)) {
1035
+ if (!enabled.includes(full) && !disabled.includes(full)) {
1036
+ enabled.push(full);
1037
+ changed = true;
1038
+ }
1039
+ }
1040
+ }
1041
+ if (changed) config.set('enabledExtensions', enabled);
1042
+ }
1043
+
1044
+ export {
1045
+ hooks,
1046
+ loadedExtensions,
1047
+ getExtension,
1048
+ getContributedPages,
1049
+ getInstalledExtensions,
1050
+ loadAllExtensions,
1051
+ loadExtension,
1052
+ unloadExtension,
1053
+ installExtension,
1054
+ removeExtension,
1055
+ enableExtension,
1056
+ disableExtension,
1057
+ getExtensionConfig,
1058
+ setExtensionConfig,
1059
+ validateManifest,
1060
+ readManifest,
1061
+ ensureBundledDir,
1062
+ deployBundledExtensions,
1063
+ verifySignature,
1064
+ computeIntegrityHash,
1065
+ getSecurityRisk,
1066
+ hasBlockedImports,
1067
+ scanDangerousPatterns,
1068
+ analyzeExtensionSecurity,
1069
+ getApprovedPermissions,
1070
+ setApprovedPermissions,
1071
+ hasPendingPermissions,
1072
+ getPendingPermissions,
1073
+ EXTENSIONS_DIR,
1074
+ BUNDLED_DIR,
1075
+ VALID_PERMISSIONS,
1076
+ HIGH_RISK_PERMISSIONS,
1077
+ BLOCKED_MODULES,
1078
+ REQUIRE_SIGNATURE,
1079
+ checkExtensionTier,
1080
+ verifyExtensionLicense,
1081
+ ensureDefaultExtensions,
1082
+ };