@vesk/adapter 0.2.9 → 0.2.11

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 (42) hide show
  1. package/dist/client-bundle.d.ts +29 -0
  2. package/dist/client-bundle.d.ts.map +1 -1
  3. package/dist/client-bundle.js +333 -52
  4. package/dist/dev-api.d.ts +78 -0
  5. package/dist/dev-api.d.ts.map +1 -0
  6. package/dist/dev-api.js +338 -0
  7. package/dist/dev-config.d.ts +48 -0
  8. package/dist/dev-config.d.ts.map +1 -0
  9. package/dist/dev-config.js +964 -0
  10. package/dist/dev-server.d.ts +85 -0
  11. package/dist/dev-server.d.ts.map +1 -1
  12. package/dist/dev-server.js +329 -8
  13. package/dist/error-codeframe.d.ts +23 -0
  14. package/dist/error-codeframe.d.ts.map +1 -0
  15. package/dist/error-codeframe.js +127 -0
  16. package/dist/error-tips.d.ts +7 -0
  17. package/dist/error-tips.d.ts.map +1 -0
  18. package/dist/error-tips.js +91 -0
  19. package/dist/hmr-utils.d.ts +14 -0
  20. package/dist/hmr-utils.d.ts.map +1 -0
  21. package/dist/hmr-utils.js +56 -0
  22. package/dist/hmr.d.ts +40 -0
  23. package/dist/hmr.d.ts.map +1 -1
  24. package/dist/hmr.js +139 -20
  25. package/dist/index.d.ts +37 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +105 -22
  28. package/dist/paths.d.ts +8 -0
  29. package/dist/paths.d.ts.map +1 -1
  30. package/dist/paths.js +32 -0
  31. package/dist/platform-handler.d.ts.map +1 -1
  32. package/dist/platform-handler.js +2 -1
  33. package/dist/plugins.d.ts +147 -0
  34. package/dist/plugins.d.ts.map +1 -0
  35. package/dist/plugins.js +1109 -0
  36. package/dist/prod-server.d.ts.map +1 -1
  37. package/dist/prod-server.js +43 -9
  38. package/dist/ssr-function.d.ts.map +1 -1
  39. package/dist/ssr-function.js +14 -2
  40. package/dist/types.d.ts +1 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +4 -4
@@ -0,0 +1,1109 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, } from 'node:fs';
2
+ import { resolve, dirname, join, extname, basename } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { spawn } from 'node:child_process';
5
+ import { resolveWithin } from '@vesk/adapter/src/paths';
6
+ export const PLUGIN_STATE_FILENAME = 'plugins.json';
7
+ const STATE_VERSION = 1;
8
+ const REGISTRY_FETCH_TIMEOUT_MS = 6000;
9
+ const REGISTRY_CACHE_TTL_MS = 5 * 60 * 1000;
10
+ const registryCache = new Map();
11
+ function defaultState() {
12
+ return { version: STATE_VERSION, plugins: [] };
13
+ }
14
+ function eqIgnoreCase(a, b) {
15
+ return String(a || '').toLowerCase() === String(b || '').toLowerCase();
16
+ }
17
+ function stateFilePath(veskDir) {
18
+ return resolve(veskDir, PLUGIN_STATE_FILENAME);
19
+ }
20
+ /**
21
+ * Read the plugin state file. Tolerates a missing file (returns defaults) and
22
+ * a corrupt file (mismatched version or invalid JSON → reseed to defaults).
23
+ */
24
+ export function readPluginState(veskDir) {
25
+ const file = stateFilePath(veskDir);
26
+ if (!existsSync(file))
27
+ return defaultState();
28
+ let raw;
29
+ try {
30
+ raw = JSON.parse(readFileSync(file, 'utf-8'));
31
+ }
32
+ catch {
33
+ return defaultState();
34
+ }
35
+ if (!raw || typeof raw !== 'object')
36
+ return defaultState();
37
+ const obj = raw;
38
+ if (obj.version !== STATE_VERSION)
39
+ return defaultState();
40
+ if (!Array.isArray(obj.plugins))
41
+ return defaultState();
42
+ return {
43
+ version: STATE_VERSION,
44
+ plugins: obj.plugins.filter((p) => !!p && typeof p === 'object' &&
45
+ typeof p.name === 'string' &&
46
+ typeof p.package === 'string' &&
47
+ typeof p.active === 'boolean'),
48
+ };
49
+ }
50
+ /** Write the plugin state file. */
51
+ export function writePluginState(veskDir, state) {
52
+ const file = stateFilePath(veskDir);
53
+ const dir = dirname(file);
54
+ if (!existsSync(dir))
55
+ mkdirSync(dir, { recursive: true });
56
+ writeFileSync(file, JSON.stringify(state, null, 2) + '\n', 'utf-8');
57
+ }
58
+ /**
59
+ * Probe whether a package is resolvable from the app directory, returning its
60
+ * resolved entry path, package root dir and package.json (if any). For a local
61
+ * plugin the `package` value may be the plugin name itself, resolved against
62
+ * app-local dirs as a fallback.
63
+ */
64
+ function resolvePackage(appDir, pkg) {
65
+ const require = createRequire(resolve(appDir, 'package.json'));
66
+ let entryPath = null;
67
+ let pkgJsonPath = null;
68
+ let pkgJson = null;
69
+ // 1. Resolve the package.json via the package spec (does not run module code).
70
+ try {
71
+ pkgJsonPath = require.resolve(`${pkg}/package.json`);
72
+ }
73
+ catch {
74
+ try {
75
+ // Fall back to resolving the package entry, then walk up to the nearest
76
+ // package.json — the entry may live in dist/ while package.json sits at
77
+ // the package root (exports-mapped packages block `<pkg>/package.json`).
78
+ // The walk never crosses outside the package's own dir tree: the first
79
+ // package.json found above the resolved entry IS the package's own, and
80
+ // we stop at the containing `node_modules` boundary / filesystem root.
81
+ entryPath = require.resolve(pkg);
82
+ let cur = dirname(entryPath);
83
+ const seen = new Set();
84
+ while (cur && cur !== dirname(cur) && !seen.has(cur)) {
85
+ seen.add(cur);
86
+ const candidate = join(cur, 'package.json');
87
+ if (existsSync(candidate)) {
88
+ pkgJsonPath = candidate;
89
+ break;
90
+ }
91
+ // Never walk above the `node_modules` directory that holds this package.
92
+ const parent = dirname(cur);
93
+ if (parent !== cur && basename(cur) === 'node_modules')
94
+ break;
95
+ cur = parent;
96
+ }
97
+ }
98
+ catch {
99
+ // not resolvable — try app-local dirs below
100
+ }
101
+ }
102
+ if (pkgJsonPath && existsSync(pkgJsonPath)) {
103
+ pkgJson = readJsonFile(pkgJsonPath);
104
+ const main = (pkgJson?.main || pkgJson?.module || 'index.js');
105
+ entryPath = resolve(dirname(pkgJsonPath), main);
106
+ }
107
+ // 2. Fall back to app-local directory checks (e.g. `./plugins/foo` or a bare
108
+ // local plugin name that is built-in to the app, not in node_modules).
109
+ let localPath = localDir(appDir, pkg);
110
+ if (!localPath) {
111
+ // maybe an app-local bare name under src/plugins etc.
112
+ for (const sub of ['src/plugins', 'plugins', 'lib/plugins']) {
113
+ const candidate = localDir(resolve(appDir, sub), pkg);
114
+ if (candidate) {
115
+ localPath = candidate;
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ if (localPath) {
121
+ const localJson = join(localPath, 'package.json');
122
+ let json = null;
123
+ if (existsSync(localJson)) {
124
+ json = readJsonFile(localJson);
125
+ }
126
+ return { installed: true, path: localPath, dir: localPath, packageJson: json ?? pkgJson };
127
+ }
128
+ const installed = !!pkgJsonPath && existsSync(pkgJsonPath);
129
+ return { installed, path: entryPath, dir: installed ? dirname(pkgJsonPath) : null, packageJson: pkgJson };
130
+ }
131
+ function localDir(base, pkg) {
132
+ if (pkg.startsWith('@')) {
133
+ // scoped: `@scope/name` → `@scope/name`
134
+ const parts = pkg.split('/');
135
+ const p = resolve(base, parts[0], parts[1] ?? '');
136
+ if (existsSync(p))
137
+ return p;
138
+ return null;
139
+ }
140
+ const p = resolve(base, pkg);
141
+ if (existsSync(p))
142
+ return p;
143
+ return null;
144
+ }
145
+ // ─── field coercion helpers ────────────────────────────────────────────────
146
+ function readJsonFile(file) {
147
+ try {
148
+ if (existsSync(file))
149
+ return JSON.parse(readFileSync(file, 'utf-8'));
150
+ }
151
+ catch {
152
+ /* ignore corrupt JSON */
153
+ }
154
+ return null;
155
+ }
156
+ function str(v) {
157
+ return typeof v === 'string' && v.trim().length > 0 ? v : null;
158
+ }
159
+ function asKeywords(v) {
160
+ if (!Array.isArray(v))
161
+ return [];
162
+ const out = [];
163
+ for (const k of v) {
164
+ if (typeof k === 'string' && k.trim().length > 0)
165
+ out.push(k);
166
+ }
167
+ return [...new Set(out)];
168
+ }
169
+ function authorToString(a) {
170
+ if (typeof a === 'string')
171
+ return str(a);
172
+ if (a && typeof a === 'object') {
173
+ const o = a;
174
+ const name = str(o.name);
175
+ const email = str(o.email);
176
+ const url = str(o.url);
177
+ if (name && email)
178
+ return `${name} <${email}>`;
179
+ if (name && url)
180
+ return `${name} (${url})`;
181
+ return name;
182
+ }
183
+ return null;
184
+ }
185
+ function repositoryToString(r) {
186
+ if (typeof r === 'string')
187
+ return str(r);
188
+ if (r && typeof r === 'object') {
189
+ const o = r;
190
+ return str(o.url) ?? str(o.repository);
191
+ }
192
+ return null;
193
+ }
194
+ /**
195
+ * Metadata precedence (contract): `vesk.meta.json` first (description,
196
+ * author, license, homepage, repository, keywords, icon), then the installed
197
+ * package.json fields. Registry enrichment (latest/updatedAt etc.) happens
198
+ * separately in `enrichPluginRecords` (async, best-effort).
199
+ */
200
+ function assemblePluginMeta(probe) {
201
+ const pkgJson = probe.packageJson || {};
202
+ const dir = probe.dir;
203
+ const meta = dir ? readJsonFile(resolve(dir, 'vesk.meta.json')) : null;
204
+ const hasMeta = !!meta && Object.keys(meta).length > 0;
205
+ const m = meta || {};
206
+ const metaSource = hasMeta ? 'vesk.meta.json' : (probe.packageJson ? 'package.json' : 'none');
207
+ const iconFile = dir ? findIconFile(dir, m) : null;
208
+ return {
209
+ metaSource,
210
+ version: str(pkgJson.version) ?? str(m.version),
211
+ description: str(m.description) ?? str(pkgJson.description),
212
+ author: authorToString(m.author ?? pkgJson.author),
213
+ license: str(m.license) ?? str(pkgJson.license),
214
+ homepage: str(m.homepage) ?? str(pkgJson.homepage),
215
+ repository: repositoryToString(m.repository ?? pkgJson.repository),
216
+ keywords: asKeywords(m.keywords ?? pkgJson.keywords),
217
+ iconFile,
218
+ };
219
+ }
220
+ /**
221
+ * Build one fully-populated PluginRecord. `active` is the RESOLVED build
222
+ * participation: the effective state value (config plugins default active)
223
+ * AND-ed with `installed` — a non-installed plugin must never report
224
+ * active:true. Registry-backed fields (latest/updatedAt) start null here and
225
+ * are filled by `enrichPluginRecords`.
226
+ */
227
+ function buildRecord(opts, appDir) {
228
+ const probe = resolvePackage(appDir, opts.pkg);
229
+ const meta = assemblePluginMeta(probe);
230
+ const installed = probe.installed;
231
+ const active = opts.activeRaw && installed;
232
+ const error = opts.error ?? (installed ? null : 'not installed');
233
+ let iconUrl = null;
234
+ if (meta.iconFile) {
235
+ iconUrl = `/__vesk/plugins/${encodeURIComponent(opts.name)}/icon`;
236
+ }
237
+ return {
238
+ name: opts.name,
239
+ package: opts.pkg,
240
+ path: probe.path,
241
+ active,
242
+ installed,
243
+ version: meta.version,
244
+ latest: null,
245
+ description: meta.description,
246
+ author: meta.author,
247
+ license: meta.license,
248
+ homepage: meta.homepage,
249
+ repository: meta.repository,
250
+ updatedAt: null,
251
+ keywords: meta.keywords,
252
+ iconUrl,
253
+ metaSource: meta.metaSource,
254
+ source: opts.source,
255
+ error,
256
+ };
257
+ }
258
+ /**
259
+ * Merge config-declared plugins and state-only entries into a unified record
260
+ * list.
261
+ *
262
+ * Precedence: a state entry (matched by name OR package, case-insensitive)
263
+ * overrides a config plugin's activation. Config plugins default to ACTIVE
264
+ * unless a matching state entry deactivates them. Entries that exist only in
265
+ * the state file are reported as source 'state'. `active` is the resolved
266
+ * build participation — always AND-ed with `installed`.
267
+ */
268
+ export function getPluginRecords(appDir, veskDir, configPluginNames) {
269
+ const state = readPluginState(veskDir);
270
+ const records = [];
271
+ for (const name of configPluginNames) {
272
+ if (typeof name !== 'string' || !name)
273
+ continue;
274
+ const stateEntry = state.plugins.find((p) => eqIgnoreCase(p.name, name) || eqIgnoreCase(p.package, name));
275
+ records.push(buildRecord({
276
+ name,
277
+ pkg: name,
278
+ source: 'config',
279
+ activeRaw: stateEntry ? stateEntry.active : true,
280
+ error: null,
281
+ }, appDir));
282
+ }
283
+ for (const entry of state.plugins) {
284
+ const isConfigPlugin = configPluginNames.some((n) => eqIgnoreCase(n, entry.name) || eqIgnoreCase(n, entry.package));
285
+ if (isConfigPlugin)
286
+ continue; // already represented as a config record
287
+ records.push(buildRecord({
288
+ name: entry.name,
289
+ pkg: entry.package || entry.name,
290
+ source: 'state',
291
+ activeRaw: entry.active,
292
+ error: null,
293
+ }, appDir));
294
+ }
295
+ return records;
296
+ }
297
+ /** Toggle a plugin's active flag in the state file (matched by name). Returns the new state. */
298
+ export function setPluginActive(veskDir, name, active) {
299
+ const state = readPluginState(veskDir);
300
+ const existing = state.plugins.find((p) => eqIgnoreCase(p.name, name));
301
+ if (existing) {
302
+ existing.active = active;
303
+ }
304
+ else {
305
+ state.plugins.push({ name, package: name, active });
306
+ }
307
+ writePluginState(veskDir, state);
308
+ return state;
309
+ }
310
+ /** Validate a package spec string loosely: non-empty, no spaces, no `..`. */
311
+ function validatePackageSpec(pkg) {
312
+ if (typeof pkg !== 'string' || pkg.trim().length === 0) {
313
+ return 'package spec is empty';
314
+ }
315
+ if (pkg !== pkg.trim())
316
+ return 'package spec must not have leading/trailing whitespace';
317
+ if (/\s/.test(pkg))
318
+ return 'package spec must not contain spaces';
319
+ if (pkg.includes('..'))
320
+ return 'package spec must not contain ".."';
321
+ if (/[\/\\][\/\\]/.test(pkg))
322
+ return 'invalid package spec';
323
+ return null;
324
+ }
325
+ /** Is the resolved package.json plausibly a Vesk plugin? */
326
+ function plausibleVeskPlugin(pkgJson) {
327
+ if (!pkgJson)
328
+ return false;
329
+ const name = typeof pkgJson.name === 'string' ? pkgJson.name : '';
330
+ if (name.startsWith('@vesk/plugin-'))
331
+ return true;
332
+ if (pkgJson.vesk === true)
333
+ return true;
334
+ // category field (e.g. "vesk-plugin" / "vk-plugin") — some registries use `category`
335
+ const cat = pkgJson.category;
336
+ if (typeof cat === 'string' && /^(vesk-plugin|vk-plugin)$/i.test(cat.trim()))
337
+ return true;
338
+ const keywords = pkgJson.keywords;
339
+ if (Array.isArray(keywords)) {
340
+ for (const k of keywords) {
341
+ const kw = String(k).toLowerCase().trim();
342
+ if (kw === 'vesk' || kw === 'vesk-plugin' || kw === 'vk-plugin')
343
+ return true;
344
+ }
345
+ }
346
+ return false;
347
+ }
348
+ async function runNpm(appDir, args, timeoutMs = 180_000) {
349
+ return new Promise((resolvePromise, reject) => {
350
+ const child = spawn('npm', args, {
351
+ cwd: appDir,
352
+ shell: false,
353
+ stdio: ['ignore', 'pipe', 'pipe'],
354
+ });
355
+ let stdout = '';
356
+ let stderr = '';
357
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
358
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
359
+ const timer = setTimeout(() => {
360
+ child.kill('SIGTERM');
361
+ reject(new Error(`npm ${args.join(' ')} timed out after ${timeoutMs}ms`));
362
+ }, timeoutMs);
363
+ child.on('error', (err) => {
364
+ clearTimeout(timer);
365
+ reject(err);
366
+ });
367
+ child.on('close', (code) => {
368
+ clearTimeout(timer);
369
+ resolvePromise({ code: code ?? 1, stdout, stderr });
370
+ });
371
+ });
372
+ }
373
+ /**
374
+ * Install a package into the app and register it as an active plugin in the
375
+ * state file. The package spec is validated first; then it is verified to be
376
+ * a Vesk plugin (via its package.json, never by importing module code). A
377
+ * plausible-but-unflagged package is still registered but flagged with an
378
+ * `error` noting it may not be a Vesk plugin.
379
+ */
380
+ export async function installPlugin(appDir, veskDir, pkg) {
381
+ const validationError = validatePackageSpec(pkg);
382
+ if (validationError) {
383
+ throw new Error(`[vesk] cannot install plugin: ${validationError}`);
384
+ }
385
+ const result = await __internals.runNpm(appDir, ['install', pkg]);
386
+ if (result.code !== 0) {
387
+ throw new Error(`npm install ${pkg} failed (exit ${result.code}): ${(result.stderr || result.stdout || '').trim()}`);
388
+ }
389
+ const probe = resolvePackage(appDir, pkg);
390
+ let error = null;
391
+ if (!plausibleVeskPlugin(probe.packageJson)) {
392
+ error = `"${pkg}" may not be a Vesk plugin (missing @vesk/plugin- prefix, "vesk":true, or "vesk" keyword)`;
393
+ }
394
+ const state = readPluginState(veskDir);
395
+ const name = probe.packageJson?.name || pkg;
396
+ const existing = state.plugins.find((p) => eqIgnoreCase(p.package, pkg) || eqIgnoreCase(p.name, name));
397
+ if (existing) {
398
+ existing.package = pkg;
399
+ existing.name = name;
400
+ existing.active = true;
401
+ }
402
+ else {
403
+ state.plugins.push({ name, package: pkg, active: true });
404
+ }
405
+ writePluginState(veskDir, state);
406
+ return buildRecord({ name, pkg, source: 'state', activeRaw: true, error }, appDir);
407
+ }
408
+ /** Uninstall a package from the app and drop all state entries whose package matches. */
409
+ export async function uninstallPlugin(appDir, veskDir, pkg) {
410
+ const validationError = validatePackageSpec(pkg);
411
+ if (validationError) {
412
+ throw new Error(`[vesk] cannot uninstall plugin: ${validationError}`);
413
+ }
414
+ const result = await __internals.runNpm(appDir, ['uninstall', pkg]);
415
+ if (result.code !== 0) {
416
+ throw new Error(`npm uninstall ${pkg} failed (exit ${result.code}): ${(result.stderr || result.stdout || '').trim()}`);
417
+ }
418
+ const state = readPluginState(veskDir);
419
+ state.plugins = state.plugins.filter((p) => !eqIgnoreCase(p.package, pkg) && !eqIgnoreCase(p.package, pkg.replace(/^@[^/]+\//, '')));
420
+ writePluginState(veskDir, state);
421
+ }
422
+ /**
423
+ * Update (reinstall at latest) an installed plugin and refresh its state entry.
424
+ * Returns the fresh record. `npm install <pkg>@latest` runs through the
425
+ * `runNpm` seam; the state entry keeps its activation, and the record is
426
+ * re-resolved against the freshly installed package.
427
+ */
428
+ export async function updatePlugin(appDir, veskDir, pkg) {
429
+ const validationError = validatePackageSpec(pkg);
430
+ if (validationError) {
431
+ throw new Error(`[vesk] cannot update plugin: ${validationError}`);
432
+ }
433
+ const result = await __internals.runNpm(appDir, ['install', `${pkg}@latest`]);
434
+ if (result.code !== 0) {
435
+ throw new Error(`npm update ${pkg} failed (exit ${result.code}): ${(result.stderr || result.stdout || '').trim()}`);
436
+ }
437
+ const probe = resolvePackage(appDir, pkg);
438
+ const state = readPluginState(veskDir);
439
+ const name = probe.packageJson?.name || pkg;
440
+ const existing = state.plugins.find((p) => eqIgnoreCase(p.package, pkg) || eqIgnoreCase(p.name, name));
441
+ if (existing) {
442
+ existing.package = pkg;
443
+ existing.name = name;
444
+ }
445
+ else {
446
+ state.plugins.push({ name, package: pkg, active: true });
447
+ }
448
+ writePluginState(veskDir, state);
449
+ return buildRecord({
450
+ name,
451
+ pkg,
452
+ source: 'state',
453
+ activeRaw: existing ? existing.active : true,
454
+ error: null,
455
+ }, appDir);
456
+ }
457
+ /**
458
+ * Filter the config-declared plugin array down to the active set.
459
+ *
460
+ * Rule (source of truth = records): for each config plugin whose `name`
461
+ * matches an ACTIVE record → keep; matched INACTIVE record → drop (never
462
+ * ships); config plugin with no matching record → keep (defaults active).
463
+ */
464
+ export function filterActivePlugins(configPlugins, records) {
465
+ return (configPlugins || []).filter((plugin) => {
466
+ if (!plugin || typeof plugin !== 'object')
467
+ return true;
468
+ const name = plugin.name;
469
+ if (typeof name !== 'string' || !name)
470
+ return true;
471
+ const record = records.find((r) => eqIgnoreCase(r.name, name));
472
+ if (!record)
473
+ return true; // no matching record → keep (defaults active)
474
+ return record.active;
475
+ });
476
+ }
477
+ // ─── icon + introspection ──────────────────────────────────────────────────
478
+ /**
479
+ * Resolve the icon a plugin declares in `vesk.meta.json` (e.g. `icon.png` /
480
+ * `icon.ico`) from its package dir. Returns the absolute file plus MIME, or
481
+ * null when nothing is declared/present. Never falls back to a default image.
482
+ */
483
+ /**
484
+ * Resolve a plugin's icon file from its package dir. Priority:
485
+ * 1. `vesk.meta.json` → explicit `icon` field (absolute/relative path),
486
+ * 2. conventional `icon.png` / `icon.ico` next to the plugin entry.
487
+ * Returns the absolute icon path or null when none exists. No default image.
488
+ */
489
+ function findIconFile(dir, meta) {
490
+ const explicit = str(meta?.icon);
491
+ if (explicit) {
492
+ const p = resolveWithin(dir, explicit);
493
+ if (p && existsSync(p) && statSync(p).isFile())
494
+ return p;
495
+ }
496
+ for (const name of ['icon.png', 'icon.ico']) {
497
+ const p = resolve(dir, name);
498
+ if (existsSync(p) && statSync(p).isFile())
499
+ return p;
500
+ }
501
+ return null;
502
+ }
503
+ /** Resolve the icon a plugin declares in `vesk.meta.json` (or conventional
504
+ * `icon.png`/`icon.ico`) from its package dir. Returns the absolute file plus
505
+ * MIME, or null when nothing is declared/present. Never a default image. */
506
+ export function findPluginIcon(appDir, name) {
507
+ const probe = resolvePackage(appDir, name);
508
+ const dir = probe.dir;
509
+ if (!dir)
510
+ return null;
511
+ const meta = readJsonFile(resolve(dir, 'vesk.meta.json'));
512
+ const iconPath = findIconFile(dir, meta);
513
+ if (!iconPath)
514
+ return null;
515
+ const ext = extname(iconPath).toLowerCase();
516
+ const mime = ext === '.png' ? 'image/png' : ext === '.ico' ? 'image/x-icon' : 'application/octet-stream';
517
+ return { file: iconPath, mime };
518
+ }
519
+ /** Flatten package.json `exports` into a subpath → file-string map. */
520
+ function flattenPackageExports(exportsField) {
521
+ if (exportsField == null)
522
+ return null;
523
+ if (typeof exportsField === 'string')
524
+ return { '.': exportsField };
525
+ if (typeof exportsField !== 'object' || Array.isArray(exportsField))
526
+ return null;
527
+ const out = {};
528
+ for (const [subpath, value] of Object.entries(exportsField)) {
529
+ const target = pickExportTarget(value);
530
+ if (target)
531
+ out[subpath] = target;
532
+ }
533
+ return Object.keys(out).length > 0 ? out : null;
534
+ }
535
+ function pickExportTarget(value) {
536
+ if (typeof value === 'string')
537
+ return value;
538
+ if (Array.isArray(value)) {
539
+ for (const v of value) {
540
+ const t = pickExportTarget(v);
541
+ if (t)
542
+ return t;
543
+ }
544
+ return '';
545
+ }
546
+ if (value && typeof value === 'object') {
547
+ const obj = value;
548
+ for (const key of ['types', 'import', 'default', 'require', 'node', 'browser']) {
549
+ const t = pickExportTarget(obj[key]);
550
+ if (t)
551
+ return t;
552
+ }
553
+ return '';
554
+ }
555
+ return '';
556
+ }
557
+ /** Resolve the plugin's `.d.ts`: package.json `types`/`typesVersions`, else a
558
+ * sibling `index.d.ts` / `main + '.d.ts'`. Returns null when nothing is found. */
559
+ function resolveDtsPath(probe, pkgJson) {
560
+ const dir = probe.dir;
561
+ if (!dir)
562
+ return null;
563
+ const candidates = [];
564
+ const types = str(pkgJson.types) ?? str(pkgJson.typings);
565
+ if (types)
566
+ candidates.push(types);
567
+ const typesVersions = pkgJson.typesVersions;
568
+ if (!types && typesVersions && typeof typesVersions === 'object') {
569
+ for (const mapped of Object.values(typesVersions)) {
570
+ if (!mapped || typeof mapped !== 'object')
571
+ continue;
572
+ const star = mapped['*'];
573
+ if (typeof star === 'string') {
574
+ candidates.push(star);
575
+ break;
576
+ }
577
+ }
578
+ }
579
+ const mainOrModule = str(pkgJson.main) ?? str(pkgJson.module);
580
+ if (mainOrModule) {
581
+ candidates.push(mainOrModule.replace(/\.[A-Za-z0-9]+$/, '.d.ts'));
582
+ candidates.push(`${mainOrModule}.d.ts`);
583
+ }
584
+ candidates.push('index.d.ts');
585
+ for (const rel of candidates) {
586
+ if (!rel)
587
+ continue;
588
+ const abs = resolveWithin(dir, rel);
589
+ if (abs && existsSync(abs) && statSync(abs).isFile())
590
+ return abs;
591
+ }
592
+ return null;
593
+ }
594
+ const IDENT_START = /[A-Za-z_$]/;
595
+ const IDENT_PART = /[A-Za-z0-9_$]/;
596
+ function isIdentStrict(t) {
597
+ return typeof t === 'string' && t.length > 0 &&
598
+ IDENT_START.test(t[0]) && !t.includes('"') && !t.includes("'") && !t.includes('`');
599
+ }
600
+ function normalizeName(t) {
601
+ const s = String(t ?? '');
602
+ if (s.length >= 2 && (s[0] === '"' || s[0] === "'" || s[0] === '`') && s[s.length - 1] === s[0]) {
603
+ return s.slice(1, -1);
604
+ }
605
+ return s;
606
+ }
607
+ function skipStringLiteral(source, i) {
608
+ const quote = source[i];
609
+ let j = i + 1;
610
+ while (j < source.length) {
611
+ if (source[j] === '\\') {
612
+ j += 2;
613
+ continue;
614
+ }
615
+ if (source[j] === quote)
616
+ return j + 1;
617
+ j++;
618
+ }
619
+ return source.length;
620
+ }
621
+ function isDotAccess(source, start) {
622
+ if (start === 0)
623
+ return false;
624
+ const prev = source[start - 1];
625
+ return prev === '.' || IDENT_PART.test(prev);
626
+ }
627
+ /** Scan to the end of one top-level statement (`;` at depth 0, or the closing
628
+ * `}` of a bare declaration such as an interface with no trailing semi). */
629
+ function findStatementEnd(source, i) {
630
+ const len = source.length;
631
+ let depth = 0;
632
+ while (i < len) {
633
+ const c = source[i];
634
+ if (c === '/' && source[i + 1] === '/') {
635
+ const nl = source.indexOf('\n', i);
636
+ i = nl === -1 ? len : nl + 1;
637
+ continue;
638
+ }
639
+ if (c === '/' && source[i + 1] === '*') {
640
+ const end = source.indexOf('*/', i + 2);
641
+ i = end === -1 ? len : end + 2;
642
+ continue;
643
+ }
644
+ if (c === '"' || c === "'" || c === '`') {
645
+ i = skipStringLiteral(source, i);
646
+ continue;
647
+ }
648
+ if (c === '{' || c === '(' || c === '[') {
649
+ depth++;
650
+ i++;
651
+ continue;
652
+ }
653
+ if (c === '}' || c === ')' || c === ']') {
654
+ depth = Math.max(0, depth - 1);
655
+ if (depth === 0 && c !== ')' && c !== ']') {
656
+ const next = i + 1 < len ? source[i + 1] : '';
657
+ if (next !== '.' && next !== '(' && next !== '[' && next !== '<')
658
+ return i + 1;
659
+ }
660
+ i++;
661
+ continue;
662
+ }
663
+ if (c === ';' && depth === 0)
664
+ return i + 1;
665
+ i++;
666
+ }
667
+ return len;
668
+ }
669
+ function tokenizeStatement(rest) {
670
+ const tokens = [];
671
+ let i = 0;
672
+ while (i < rest.length) {
673
+ const c = rest[i];
674
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
675
+ i++;
676
+ continue;
677
+ }
678
+ if (c === '/' && rest[i + 1] === '/') {
679
+ const nl = rest.indexOf('\n', i);
680
+ i = nl === -1 ? rest.length : nl + 1;
681
+ continue;
682
+ }
683
+ if (c === '/' && rest[i + 1] === '*') {
684
+ const end = rest.indexOf('*/', i + 2);
685
+ i = end === -1 ? rest.length : end + 2;
686
+ continue;
687
+ }
688
+ if (c === '"' || c === "'" || c === '`') {
689
+ const quote = c;
690
+ let j = i + 1;
691
+ while (j < rest.length && rest[j] !== quote) {
692
+ if (rest[j] === '\\')
693
+ j += 2;
694
+ else
695
+ j++;
696
+ }
697
+ tokens.push(rest.slice(i, Math.min(j + 1, rest.length)));
698
+ i = j + 1;
699
+ continue;
700
+ }
701
+ if (c === '.' && rest[i + 1] === '.' && rest[i + 2] === '.') {
702
+ tokens.push('...');
703
+ i += 3;
704
+ continue;
705
+ }
706
+ if (IDENT_START.test(c)) {
707
+ const start = i;
708
+ while (i < rest.length && IDENT_PART.test(rest[i]))
709
+ i++;
710
+ tokens.push(rest.slice(start, i));
711
+ continue;
712
+ }
713
+ tokens.push(c);
714
+ i++;
715
+ }
716
+ return tokens;
717
+ }
718
+ /** Names exported by a `export { ... }` / `export type { ... }` list. */
719
+ function namesFromExportList(tokens, startIdx) {
720
+ const names = [];
721
+ const seen = new Set();
722
+ const add = (n) => {
723
+ const v = normalizeName(n);
724
+ if (v && !seen.has(v)) {
725
+ seen.add(v);
726
+ names.push(v);
727
+ }
728
+ };
729
+ let i = startIdx;
730
+ while (i < tokens.length && tokens[i] !== '}') {
731
+ const cur = tokens[i];
732
+ const next = tokens[i + 1];
733
+ if (cur === ',' || cur === 'type') {
734
+ i++;
735
+ continue;
736
+ }
737
+ if (next === 'as') {
738
+ add(tokens[i + 2]);
739
+ i += 3;
740
+ continue;
741
+ }
742
+ if (cur !== 'as' && cur !== '}') {
743
+ add(cur);
744
+ i += 1;
745
+ continue;
746
+ }
747
+ i++;
748
+ }
749
+ return names;
750
+ }
751
+ /** Binding names of a `const`/`let`/`var` export declaration. */
752
+ function declNames(tokens, startIdx) {
753
+ const names = [];
754
+ const seen = new Set();
755
+ const add = (n) => {
756
+ const v = normalizeName(n);
757
+ if (v && !seen.has(v)) {
758
+ seen.add(v);
759
+ names.push(v);
760
+ }
761
+ };
762
+ const isOpen = (t) => t === '{' || t === '(' || t === '[';
763
+ const isClose = (t) => t === '}' || t === ')' || t === ']';
764
+ const patternStack = [];
765
+ let depth = 0;
766
+ let seenEquals = false;
767
+ let inType = false;
768
+ for (let i = startIdx; i < tokens.length; i++) {
769
+ const tok = tokens[i];
770
+ if (tok === ';')
771
+ break;
772
+ if (inType) {
773
+ if (tok === '=' && depth === 0) {
774
+ inType = false;
775
+ seenEquals = true;
776
+ }
777
+ else if (isOpen(tok))
778
+ depth++;
779
+ else if (isClose(tok))
780
+ depth = Math.max(0, depth - 1);
781
+ continue;
782
+ }
783
+ if (isOpen(tok)) {
784
+ depth++;
785
+ if (!seenEquals)
786
+ patternStack.push(tok);
787
+ continue;
788
+ }
789
+ if (isClose(tok)) {
790
+ depth = Math.max(0, depth - 1);
791
+ if (!seenEquals && patternStack.length)
792
+ patternStack.pop();
793
+ continue;
794
+ }
795
+ if (depth === 0 && tok === ':' && !seenEquals) {
796
+ inType = true;
797
+ continue;
798
+ }
799
+ if (depth === 0 && tok === '=') {
800
+ seenEquals = true;
801
+ continue;
802
+ }
803
+ if (depth === 0 && tok === ',') {
804
+ seenEquals = false;
805
+ continue;
806
+ }
807
+ if (tok === '...') {
808
+ if (!seenEquals && isIdentStrict(tokens[i + 1]))
809
+ add(tokens[i + 1]);
810
+ i++;
811
+ continue;
812
+ }
813
+ if (!seenEquals && isIdentStrict(tok))
814
+ add(tok);
815
+ }
816
+ return names;
817
+ }
818
+ /** Names exported by one top-level `export ...` statement body (after the keyword). */
819
+ function namesFromExportStatement(statementText) {
820
+ const tokens = tokenizeStatement(statementText);
821
+ if (tokens.length === 0)
822
+ return [];
823
+ let idx = 0;
824
+ const MODIFIERS = new Set(['declare', 'abstract', 'async', 'readonly', 'global']);
825
+ while (idx < tokens.length && MODIFIERS.has(tokens[idx]))
826
+ idx++;
827
+ const kw = tokens[idx];
828
+ if (kw === '{')
829
+ return namesFromExportList(tokens, idx + 1);
830
+ if (kw === '*') {
831
+ if (tokens[idx + 1] === 'as' && isIdentStrict(tokens[idx + 2]))
832
+ return [tokens[idx + 2]];
833
+ return [];
834
+ }
835
+ if (kw === '=')
836
+ return [];
837
+ if (kw === 'as') {
838
+ const nsIdx = tokens.indexOf('namespace', idx + 1);
839
+ if (nsIdx !== -1 && isIdentStrict(tokens[nsIdx + 1]))
840
+ return [tokens[nsIdx + 1]];
841
+ return [];
842
+ }
843
+ if (kw === 'default')
844
+ return ['default'];
845
+ if (kw === 'import')
846
+ return [];
847
+ if (kw === 'type') {
848
+ if (tokens[idx + 1] === '{')
849
+ return namesFromExportList(tokens, idx + 2);
850
+ if (isIdentStrict(tokens[idx + 1]))
851
+ return [tokens[idx + 1]];
852
+ return [];
853
+ }
854
+ if (kw === 'var' || kw === 'let')
855
+ return declNames(tokens, idx + 1);
856
+ if (kw === 'const') {
857
+ if (tokens[idx + 1] === 'enum') {
858
+ if (isIdentStrict(tokens[idx + 2]))
859
+ return [tokens[idx + 2]];
860
+ return [];
861
+ }
862
+ return declNames(tokens, idx + 1);
863
+ }
864
+ if (kw === 'function' || kw === 'class' || kw === 'interface' ||
865
+ kw === 'enum' || kw === 'namespace' || kw === 'module') {
866
+ let nameIdx = idx + 1;
867
+ if (tokens[nameIdx] === '*')
868
+ nameIdx++;
869
+ if (isIdentStrict(tokens[nameIdx]))
870
+ return [tokens[nameIdx]];
871
+ return [];
872
+ }
873
+ return [];
874
+ }
875
+ /**
876
+ * Parse top-level `export ...` declarations from a `.d.ts` source into the
877
+ * exported-name list (adapter text processing — no module execution). Wildcard
878
+ * re-exports contribute nothing; `export { a as b }` yields `b`.
879
+ */
880
+ export function parseDtsExports(source) {
881
+ const names = [];
882
+ const seen = new Set();
883
+ const add = (n) => {
884
+ const v = normalizeName(n);
885
+ if (v && !seen.has(v)) {
886
+ seen.add(v);
887
+ names.push(v);
888
+ }
889
+ };
890
+ const len = source.length;
891
+ let i = 0;
892
+ let depth = 0;
893
+ while (i < len) {
894
+ const c = source[i];
895
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
896
+ i++;
897
+ continue;
898
+ }
899
+ if (c === '/' && source[i + 1] === '/') {
900
+ const nl = source.indexOf('\n', i);
901
+ i = nl === -1 ? len : nl + 1;
902
+ continue;
903
+ }
904
+ if (c === '/' && source[i + 1] === '*') {
905
+ const end = source.indexOf('*/', i + 2);
906
+ i = end === -1 ? len : end + 2;
907
+ continue;
908
+ }
909
+ if (c === '"' || c === "'" || c === '`') {
910
+ i = skipStringLiteral(source, i);
911
+ continue;
912
+ }
913
+ if (c === '{' || c === '(' || c === '[') {
914
+ depth++;
915
+ i++;
916
+ continue;
917
+ }
918
+ if (c === '}' || c === ')' || c === ']') {
919
+ depth = Math.max(0, depth - 1);
920
+ i++;
921
+ continue;
922
+ }
923
+ if (IDENT_START.test(c)) {
924
+ const start = i;
925
+ while (i < len && IDENT_PART.test(source[i]))
926
+ i++;
927
+ const word = source.slice(start, i);
928
+ if (depth === 0 && word === 'export' && !isDotAccess(source, start)) {
929
+ const stmtEnd = findStatementEnd(source, i);
930
+ const exported = namesFromExportStatement(source.slice(i, stmtEnd));
931
+ for (const n of exported)
932
+ add(n);
933
+ i = stmtEnd;
934
+ continue;
935
+ }
936
+ continue;
937
+ }
938
+ i++;
939
+ }
940
+ return names;
941
+ }
942
+ /**
943
+ * Introspect an installed plugin's public surface WITHOUT importing/executing
944
+ * it: resolved entry (package.json main/module), flat package.json `exports`
945
+ * map, and the `.d.ts`-parsed export names.
946
+ */
947
+ export function introspectPlugin(appDir, name) {
948
+ const probe = resolvePackage(appDir, name);
949
+ if (!probe.installed || !probe.packageJson) {
950
+ return { ok: false, name, entry: null, packageJsonExports: null, dtsPath: null, dtsExports: [] };
951
+ }
952
+ const pkgJson = probe.packageJson;
953
+ const pkgName = str(pkgJson.name) ?? name;
954
+ const exportsMap = flattenPackageExports(pkgJson.exports);
955
+ const dtsPath = resolveDtsPath(probe, pkgJson);
956
+ const dtsExports = dtsPath ? parseDtsExports(readFileSync(dtsPath, 'utf-8')) : [];
957
+ return {
958
+ ok: true,
959
+ name: pkgName,
960
+ entry: probe.path,
961
+ packageJsonExports: exportsMap,
962
+ dtsPath,
963
+ dtsExports,
964
+ };
965
+ }
966
+ async function fetchWithSignal(url) {
967
+ const fetchFn = __internals.fetch;
968
+ if (typeof fetchFn !== 'function')
969
+ return null;
970
+ const ctrl = new AbortController();
971
+ const timer = setTimeout(() => ctrl.abort(), REGISTRY_FETCH_TIMEOUT_MS);
972
+ try {
973
+ return await fetchFn(url, { signal: ctrl.signal });
974
+ }
975
+ catch {
976
+ return null;
977
+ }
978
+ finally {
979
+ clearTimeout(timer);
980
+ }
981
+ }
982
+ async function fetchRegistryJson(url) {
983
+ const cached = registryCache.get(url);
984
+ const now = Date.now();
985
+ if (cached && now - cached.at < REGISTRY_CACHE_TTL_MS)
986
+ return cached.value;
987
+ const res = await fetchWithSignal(url);
988
+ let value = null;
989
+ if (res && res.ok) {
990
+ try {
991
+ value = await res.json();
992
+ }
993
+ catch {
994
+ value = null;
995
+ }
996
+ }
997
+ // only cache successful responses; a failed/timed-out fetch must be retried
998
+ // on the next request instead of returning a stale empty result for 5 min
999
+ if (value !== null && res && res.ok)
1000
+ registryCache.set(url, { at: now, value });
1001
+ return value;
1002
+ }
1003
+ /** Strip a version/tag suffix (`foo@1.2.3` → `foo`, keep `@scope/name`). */
1004
+ function normalizeRegistryName(pkg) {
1005
+ const name = String(pkg || '');
1006
+ if (!name)
1007
+ return name;
1008
+ const at = name.lastIndexOf('@');
1009
+ if (at > 0)
1010
+ return name.slice(0, at);
1011
+ return name;
1012
+ }
1013
+ /**
1014
+ * Best-effort npm-registry enrichment for a single package. Non-fatal: any
1015
+ * fetch/timeout/json failure yields null so a record stays readable.
1016
+ */
1017
+ async function fetchPackageRegistryInfo(pkg) {
1018
+ const name = normalizeRegistryName(pkg);
1019
+ if (!name)
1020
+ return null;
1021
+ const url = `https://registry.npmjs.org/${name.split('/').map(encodeURIComponent).join('/')}`;
1022
+ const data = await fetchRegistryJson(url);
1023
+ if (!data || typeof data !== 'object')
1024
+ return null;
1025
+ const obj = data;
1026
+ const distTags = obj['dist-tags'];
1027
+ const time = obj.time;
1028
+ let latest = null;
1029
+ if (distTags && typeof distTags === 'object') {
1030
+ latest = str(distTags.latest);
1031
+ }
1032
+ let updatedAt = null;
1033
+ if (time && typeof time === 'object') {
1034
+ updatedAt = str(time.modified);
1035
+ }
1036
+ return {
1037
+ latest,
1038
+ updatedAt,
1039
+ author: authorToString(obj.author),
1040
+ repository: repositoryToString(obj.repository),
1041
+ license: str(obj.license),
1042
+ description: str(obj.description),
1043
+ };
1044
+ }
1045
+ /**
1046
+ * Fill registry-backed fields (latest, updatedAt, and any still-empty
1047
+ * author/repository/license/description) on the given records, best-effort and
1048
+ * cached so repeated GETs never hang. Returns the same array (mutated).
1049
+ */
1050
+ export async function enrichPluginRecords(records) {
1051
+ for (const record of records) {
1052
+ if (!record.installed)
1053
+ continue;
1054
+ const info = await fetchPackageRegistryInfo(record.package);
1055
+ if (!info)
1056
+ continue;
1057
+ if (record.latest === null && info.latest)
1058
+ record.latest = info.latest;
1059
+ if (record.updatedAt === null && info.updatedAt)
1060
+ record.updatedAt = info.updatedAt;
1061
+ if (record.author === null)
1062
+ record.author = info.author;
1063
+ if (record.repository === null)
1064
+ record.repository = info.repository;
1065
+ if (record.license === null)
1066
+ record.license = info.license;
1067
+ if (record.description === null)
1068
+ record.description = info.description;
1069
+ }
1070
+ return records;
1071
+ }
1072
+ /**
1073
+ * Search the npm registry (proxied). Empty `q` surfaces a curated `@vesk/*`
1074
+ * scope set (`scope:vesk`). Best-effort: an unreachable registry yields `[]`.
1075
+ */
1076
+ export async function searchPlugins(q) {
1077
+ const query = q && q.trim() ? q.trim() : 'scope:vesk';
1078
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}`;
1079
+ const data = await fetchRegistryJson(url);
1080
+ if (!data || typeof data !== 'object')
1081
+ return [];
1082
+ const objects = data.objects;
1083
+ if (!Array.isArray(objects))
1084
+ return [];
1085
+ const out = [];
1086
+ for (const obj of objects) {
1087
+ const pkgField = obj?.package;
1088
+ if (!pkgField || typeof pkgField !== 'object')
1089
+ continue;
1090
+ const p = pkgField;
1091
+ const links = p.links && typeof p.links === 'object' ? p.links : null;
1092
+ out.push({
1093
+ name: str(p.name) ?? '',
1094
+ version: str(p.version),
1095
+ description: str(p.description),
1096
+ author: authorToString(p.author),
1097
+ date: str(p.date),
1098
+ keywords: asKeywords(p.keywords),
1099
+ links: links ? { ...links } : null,
1100
+ });
1101
+ }
1102
+ return out;
1103
+ }
1104
+ // ─── internals (test seam) ──────────────────────────────────────────────────
1105
+ export const __internals = {
1106
+ runNpm,
1107
+ fetch: ((url, init) => globalThis.fetch(url, init)),
1108
+ clearRegistryCache: () => { registryCache.clear(); },
1109
+ };