@triflare/fx 1.0.3 → 2.0.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 (50) hide show
  1. package/README.md +32 -0
  2. package/bin/fx.js +4 -1162
  3. package/package.json +5 -3
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/cli/commands/build.js +98 -0
  7. package/src/cli/commands/catalog.js +42 -0
  8. package/src/cli/commands/check.js +44 -0
  9. package/src/cli/commands/completion.js +54 -0
  10. package/src/cli/commands/decompile.js +266 -0
  11. package/src/cli/commands/dev.js +179 -0
  12. package/src/cli/commands/extension.js +110 -0
  13. package/src/cli/commands/index.js +39 -0
  14. package/src/cli/commands/init.js +169 -0
  15. package/src/cli/commands/package.js +137 -0
  16. package/src/cli/commands/sprite/add.js +85 -0
  17. package/src/cli/commands/sprite/build.js +62 -0
  18. package/src/cli/commands/sprite/helpers.js +66 -0
  19. package/src/cli/commands/sprite/index.js +29 -0
  20. package/src/cli/commands/sprite/list.js +31 -0
  21. package/src/cli/commands/sprite/remove.js +51 -0
  22. package/src/cli/commands/vendor/add.js +98 -0
  23. package/src/cli/commands/vendor/helpers.js +50 -0
  24. package/src/cli/commands/vendor/index.js +39 -0
  25. package/src/cli/commands/vendor/install.js +64 -0
  26. package/src/cli/commands/vendor/list.js +45 -0
  27. package/src/cli/commands/vendor/migrate.js +47 -0
  28. package/src/cli/commands/vendor/outdated.js +51 -0
  29. package/src/cli/commands/vendor/remove.js +34 -0
  30. package/src/cli/commands/vendor/update.js +49 -0
  31. package/src/cli/commands/vendor/verify.js +44 -0
  32. package/src/cli/completion/bash.js +10 -0
  33. package/src/cli/completion/fish.js +9 -0
  34. package/src/cli/completion/index.js +97 -0
  35. package/src/cli/completion/zsh.js +11 -0
  36. package/src/cli/context.js +257 -0
  37. package/src/cli/define-command.js +140 -0
  38. package/src/cli/errors.js +111 -0
  39. package/src/cli/format.js +31 -0
  40. package/src/cli/prompts.js +183 -0
  41. package/src/cli/reporter.js +194 -0
  42. package/src/cli/run.js +248 -0
  43. package/src/cli/templates/index.js +112 -0
  44. package/src/cli/theme.js +84 -0
  45. package/src/config-edit.js +280 -1
  46. package/src/config.js +137 -3
  47. package/src/extensions.js +72 -19
  48. package/src/index.js +21 -0
  49. package/src/vendor.js +1323 -0
  50. package/vendor/pm-packager-dist/.fx-build.json +1 -1
@@ -54,7 +54,8 @@ function childKey(lines, start, end, key) {
54
54
  }
55
55
 
56
56
  function childIndent(lines, start, end) {
57
- for (let index = start + 1; index < end; index++) {
57
+ const limit = Math.min(end, lines.length);
58
+ for (let index = start + 1; index < limit; index++) {
58
59
  const match = /^([ \t]+)\S/.exec(content(lines[index]));
59
60
  if (match) return match[1];
60
61
  }
@@ -336,3 +337,281 @@ export function removeProjectExtension(configPath, target) {
336
337
  unsandboxedRemoved: unsandboxedRemoved.length > 0,
337
338
  };
338
339
  }
340
+
341
+ /**
342
+ * Remove `project.extensions` entirely (used by migration).
343
+ *
344
+ * Unlike {@link removeProjectExtension}, this drops the key itself instead of
345
+ * leaving an empty list, so a fully migrated file has no legacy section left.
346
+ */
347
+ export function removeProjectExtensions(configPath) {
348
+ const text = fs.readFileSync(configPath, "utf8");
349
+ const lines = splitLines(text);
350
+ const project = topLevel(lines, "project");
351
+ if (!project) return [];
352
+ const { value: rest, comment } = splitComment(project.rest);
353
+
354
+ if (rest.startsWith("{")) {
355
+ if (!rest.endsWith("}"))
356
+ throw new Error("project must be a mapping to edit it");
357
+ const mapping = parseValue(rest);
358
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
359
+ throw new Error("project must be a mapping to edit it");
360
+ if (!Object.hasOwn(mapping, "extensions")) return [];
361
+
362
+ const removed = stringList(mapping.extensions);
363
+ delete mapping.extensions;
364
+ const line = lines[project.index];
365
+ lines[project.index] =
366
+ `project: ${JSON.stringify(mapping)}` +
367
+ `${comment ? ` ${comment}` : ""}${ending(line) || eolOf(text)}`;
368
+ writeDocument(configPath, lines.join(""));
369
+ return removed;
370
+ }
371
+
372
+ const bounds = projectBounds(lines);
373
+ if (!bounds) return [];
374
+ const list = readList(lines, "extensions", bounds.index, bounds.end);
375
+ if (!list) return [];
376
+
377
+ const removed = [...list.values];
378
+ if (list.style === "block") {
379
+ const end = Math.max(list.keyLine.index, list.lastLine) + 1;
380
+ lines.splice(list.keyLine.index, end - list.keyLine.index);
381
+ } else {
382
+ lines.splice(list.keyLine.index, 1);
383
+ }
384
+ writeDocument(configPath, lines.join(""));
385
+ return removed;
386
+ }
387
+
388
+ // --------------------------------------------------------------- vendor edits
389
+
390
+ const SAFE_KEY = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
391
+
392
+ /** Quote a mapping key when YAML would otherwise parse it as non-text. */
393
+ function quoteKey(key) {
394
+ // YAML 1.1 treats bare yes/no/on/off/null as non-strings.
395
+ return SAFE_KEY.test(key) &&
396
+ !/^(?:y|yes|n|no|on|off|null|true|false)$/i.test(key)
397
+ ? key
398
+ : JSON.stringify(String(key));
399
+ }
400
+
401
+ /** Serialize one vendor entry, expanding options only when needed. */
402
+ function vendorEntryLines(name, source, options, indent, eol) {
403
+ const extra = [];
404
+ if (options.unsandboxed) extra.push(`${indent} unsandboxed: true${eol}`);
405
+ if (options.volatile !== undefined)
406
+ extra.push(`${indent} volatile: ${Boolean(options.volatile)}${eol}`);
407
+ if (extra.length === 0)
408
+ return [`${indent}${quoteKey(name)}: ${quote(source)}${eol}`];
409
+ return [
410
+ `${indent}${quoteKey(name)}:${eol}`,
411
+ `${indent} url: ${quote(source)}${eol}`,
412
+ ...extra,
413
+ ];
414
+ }
415
+
416
+ /** Locate a top-level mapping, accepting an inline flow mapping. */
417
+ function topLevelBounds(lines, key) {
418
+ const found = topLevel(lines, key);
419
+ if (!found) return null;
420
+ const { value: rest, comment } = splitComment(found.rest);
421
+ if (rest !== "") {
422
+ // A flow mapping can be expanded losslessly (comments on the line aside).
423
+ if (rest.startsWith("{") && rest.endsWith("}")) {
424
+ const mapping = parseValue(rest);
425
+ if (mapping && typeof mapping === "object" && !Array.isArray(mapping))
426
+ return {
427
+ index: found.index,
428
+ end: found.index + 1,
429
+ inline: rest,
430
+ comment,
431
+ };
432
+ }
433
+ throw new Error(`${key} must be a block mapping to edit it`);
434
+ }
435
+ return {
436
+ index: found.index,
437
+ end: sectionEnd(lines, found.index),
438
+ inline: null,
439
+ comment,
440
+ };
441
+ }
442
+
443
+ /** Find direct child entries and their line ranges in a mapping. */
444
+ function mappingEntrySpans(lines, start, end) {
445
+ const indent = childIndent(lines, start, end);
446
+ const spans = [];
447
+ const limit = Math.min(end, lines.length);
448
+ for (let index = start + 1; index < limit; index++) {
449
+ const text = content(lines[index]);
450
+ if (text.trim() === "" || /^[ \t]*#/.test(text)) continue;
451
+ const outer = /^([ \t]+)(.*)$/.exec(text);
452
+ if (!outer || outer[1] !== indent) continue;
453
+ const match = /^(.+?)[ \t]*:[ \t]*(.*)$/.exec(outer[2]);
454
+ if (!match) continue;
455
+ let key;
456
+ try {
457
+ const parsed = yaml.load(`${match[1]}: null`);
458
+ key =
459
+ parsed && typeof parsed === "object" ? Object.keys(parsed)[0] : null;
460
+ } catch {
461
+ continue;
462
+ }
463
+ if (key == null) continue;
464
+ spans.push({ index, indent, key: String(key), rest: match[2] });
465
+ }
466
+ for (let index = 0; index < spans.length; index++)
467
+ spans[index].end = index + 1 < spans.length ? spans[index + 1].index : end;
468
+ return spans;
469
+ }
470
+
471
+ /** Replace a half-open line range in place. */
472
+ function replaceRange(lines, start, end, added) {
473
+ lines.splice(start, end - start, ...added);
474
+ }
475
+
476
+ /**
477
+ * Add or replace a `vendor:` entry without reformatting the rest of fx.yml.
478
+ *
479
+ * @param {string} configPath
480
+ * @param {string} name
481
+ * @param {string} source
482
+ * @param {{unsandboxed?: boolean, volatile?: boolean}} [options]
483
+ * @returns {{added: boolean, updated: boolean}}
484
+ */
485
+ export function addVendorEntry(configPath, name, source, options = {}) {
486
+ const text = fs.readFileSync(configPath, "utf8");
487
+ const lines = splitLines(text);
488
+ const eol = eolOf(text);
489
+ const key = String(name);
490
+ let added = false;
491
+ let updated = false;
492
+
493
+ const bounds = topLevelBounds(lines, "vendor");
494
+ if (!bounds) {
495
+ if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
496
+ lines[lines.length - 1] += eol;
497
+ lines.push(
498
+ `vendor:${eol}`,
499
+ ...vendorEntryLines(key, source, options, " ", eol),
500
+ );
501
+ added = true;
502
+ } else if (bounds.inline) {
503
+ const mapping = parseValue(bounds.inline) || {};
504
+ updated = Object.hasOwn(mapping, key);
505
+ added = !updated;
506
+ mapping[key] =
507
+ options.unsandboxed || options.volatile !== undefined
508
+ ? {
509
+ url: source,
510
+ ...(options.unsandboxed ? { unsandboxed: true } : {}),
511
+ ...(options.volatile !== undefined
512
+ ? { volatile: Boolean(options.volatile) }
513
+ : {}),
514
+ }
515
+ : source;
516
+ const dumped = yaml
517
+ .dump({ vendor: mapping }, { lineWidth: -1, noRefs: true })
518
+ .split("\n")
519
+ .filter((line, index, all) => !(index === all.length - 1 && line === ""))
520
+ .map((line) => `${line}${eol}`);
521
+ if (bounds.comment) dumped[0] = `vendor: ${bounds.comment}${eol}`;
522
+ replaceRange(lines, bounds.index, bounds.index + 1, dumped);
523
+ } else {
524
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
525
+ const existing = spans.find((span) => span.key === key);
526
+ if (existing) {
527
+ replaceRange(
528
+ lines,
529
+ existing.index,
530
+ existing.end,
531
+ vendorEntryLines(key, source, options, existing.indent, eol),
532
+ );
533
+ updated = true;
534
+ } else {
535
+ const indent = childIndent(lines, bounds.index, bounds.end);
536
+ insertAt(
537
+ lines,
538
+ bounds.end,
539
+ vendorEntryLines(key, source, options, indent, eol),
540
+ eol,
541
+ );
542
+ added = true;
543
+ }
544
+ }
545
+
546
+ if (added || updated) writeDocument(configPath, lines.join(""));
547
+ return { added, updated };
548
+ }
549
+
550
+ /**
551
+ * Remove a `vendor:` entry.
552
+ * @param {string} configPath
553
+ * @param {string} name
554
+ * @returns {{removed: string|null}}
555
+ */
556
+ export function removeVendorEntry(configPath, name) {
557
+ const text = fs.readFileSync(configPath, "utf8");
558
+ const lines = splitLines(text);
559
+ const eol = eolOf(text);
560
+ const key = String(name);
561
+ const bounds = topLevelBounds(lines, "vendor");
562
+ if (!bounds) return { removed: null };
563
+
564
+ if (bounds.inline) {
565
+ const mapping = parseValue(bounds.inline);
566
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
567
+ return { removed: null };
568
+ if (!Object.hasOwn(mapping, key)) return { removed: null };
569
+ const source =
570
+ typeof mapping[key] === "string"
571
+ ? mapping[key]
572
+ : (mapping[key]?.url ?? mapping[key]?.source ?? null);
573
+ delete mapping[key];
574
+ const rest = Object.keys(mapping).length
575
+ ? ` ${JSON.stringify(mapping)}`
576
+ : " {}";
577
+ const comment = bounds.comment ? ` ${bounds.comment}` : "";
578
+ const line = lines[bounds.index];
579
+ lines[bounds.index] = `vendor:${rest}${comment}${
580
+ /\r?\n$/.test(line) ? ending(line) : eol
581
+ }`;
582
+ writeDocument(configPath, lines.join(""));
583
+ return { removed: source };
584
+ }
585
+
586
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
587
+ const existing = spans.find((span) => span.key === key);
588
+ if (!existing) return { removed: null };
589
+
590
+ let source = null;
591
+ try {
592
+ const parsed = yaml.load(
593
+ [
594
+ content(lines[bounds.index]),
595
+ ...lines.slice(existing.index, existing.end),
596
+ ]
597
+ .join("\n")
598
+ .replace(/^vendor:/, "vendor:"),
599
+ );
600
+ const entry = parsed?.vendor?.[key];
601
+ source =
602
+ typeof entry === "string"
603
+ ? entry
604
+ : (entry?.url ?? entry?.source ?? entry?.path ?? null);
605
+ } catch {
606
+ // The entry is removed either way.
607
+ }
608
+
609
+ replaceRange(lines, existing.index, existing.end, []);
610
+ const end = bounds.end - (existing.end - existing.index);
611
+ const remaining = mappingEntrySpans(lines, bounds.index, end);
612
+ if (remaining.length === 0) {
613
+ replaceRange(lines, bounds.index, end, [`vendor: {}${eol}`]);
614
+ }
615
+ writeDocument(configPath, lines.join(""));
616
+ return { removed: source ?? key };
617
+ }
package/src/config.js CHANGED
@@ -31,8 +31,112 @@ export const DEFAULT_PROJECT = {
31
31
  */
32
32
  export const DEFAULT_FX = {
33
33
  partialSprites: false,
34
+ vendorDir: "extensions",
34
35
  };
35
36
 
37
+ /** Default directory that receives vendored extension sources. */
38
+ export const DEFAULT_VENDOR_DIR = "extensions";
39
+
40
+ /** Source schemes that identify a remote extension. */
41
+ const REMOTE_SOURCE = /^https?:\/\//i;
42
+ const DATA_SOURCE = /^data:/i;
43
+
44
+ /** True for http(s) extension sources. */
45
+ function isRemoteSource(source) {
46
+ return REMOTE_SOURCE.test(String(source));
47
+ }
48
+
49
+ /** True for embedded data-URI extension sources. */
50
+ function isDataSource(source) {
51
+ return DATA_SOURCE.test(String(source));
52
+ }
53
+
54
+ /** Localhost/loopback URLs can point at a development server that changes. */
55
+ export function isLocalhostSource(source) {
56
+ if (!isRemoteSource(source)) return false;
57
+ try {
58
+ const url = new URL(String(source));
59
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
60
+ return (
61
+ host === "localhost" ||
62
+ host.endsWith(".localhost") ||
63
+ host === "0.0.0.0" ||
64
+ host === "::1" ||
65
+ /^127\./.test(host)
66
+ );
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Normalize one `vendor:` entry. A string is shorthand for `{ url }`; a
74
+ * mapping may add `unsandboxed`, `volatile` and any future fields.
75
+ *
76
+ * @param {string} name
77
+ * @param {unknown} raw
78
+ * @returns {object}
79
+ */
80
+ export function normalizeVendorEntry(name, raw) {
81
+ const key = String(name);
82
+ let source;
83
+ let options = {};
84
+ if (typeof raw === "string" || typeof raw === "number") {
85
+ source = String(raw);
86
+ } else if (isPlainObject(raw)) {
87
+ source = raw.url ?? raw.source ?? raw.path ?? raw.file;
88
+ options = { ...raw };
89
+ } else {
90
+ throw new Error(
91
+ `${configFilename()}: vendor.${key} must be a URL, path, or mapping`,
92
+ );
93
+ }
94
+ if (typeof source !== "string" || source.trim() === "")
95
+ throw new Error(
96
+ `${configFilename()}: vendor.${key} needs a "url" (or "source"/"path")`,
97
+ );
98
+ source = source.trim();
99
+
100
+ const remote = isRemoteSource(source);
101
+ const local = !remote && !isDataSource(source);
102
+ const unsandboxed =
103
+ options.unsandboxed === true ||
104
+ (typeof options.unsandboxed === "string" &&
105
+ options.unsandboxed.trim() !== "");
106
+ const volatile =
107
+ options.volatile == null
108
+ ? remote && isLocalhostSource(source)
109
+ : Boolean(options.volatile);
110
+
111
+ return {
112
+ ...options,
113
+ name: key,
114
+ source,
115
+ url: source,
116
+ remote,
117
+ local,
118
+ data: isDataSource(source),
119
+ volatile,
120
+ unsandboxed,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Normalize the top-level `vendor:` mapping. Order is load order, so this is an
126
+ * array rather than a plain object.
127
+ *
128
+ * @param {unknown} raw
129
+ * @returns {object[]}
130
+ */
131
+ export function normalizeVendor(raw = {}) {
132
+ if (raw == null) return [];
133
+ if (!isPlainObject(raw))
134
+ throw new Error(`${configFilename()}: "vendor" must be a mapping`);
135
+ return Object.entries(raw).map(([name, entry]) =>
136
+ normalizeVendorEntry(name, entry),
137
+ );
138
+ }
139
+
36
140
  /**
37
141
  * Packaging defaults. These never reach PenguinMod itself; they are handed to
38
142
  * the PenguinMod Packager when `fx package` or `fx dev` runs.
@@ -281,6 +385,11 @@ export function normalizeFx(raw = {}) {
281
385
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
282
386
  if (raw.partialSprites != null)
283
387
  fx.partialSprites = Boolean(raw.partialSprites);
388
+ if (raw.vendorDir != null) {
389
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
390
+ throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
391
+ fx.vendorDir = raw.vendorDir.trim();
392
+ }
284
393
  }
285
394
  return fx;
286
395
  }
@@ -288,7 +397,7 @@ export function normalizeFx(raw = {}) {
288
397
  /**
289
398
  * Parse a YAML config string.
290
399
  * @param {string} text
291
- * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, raw: object}}
400
+ * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, package: typeof DEFAULT_PACKAGE, vendor: object[], vendorDir: string, raw: object}}
292
401
  */
293
402
  export function parseConfig(text) {
294
403
  const raw = yaml.load(text) || {};
@@ -298,10 +407,20 @@ export function parseConfig(text) {
298
407
  );
299
408
  if (raw.fx != null && (typeof raw.fx !== "object" || Array.isArray(raw.fx)))
300
409
  throw new Error(`${configFilename()} must contain an "fx" mapping`);
410
+ const fx = normalizeFx(raw.fx || {});
411
+ // `vendorDir` is documented at the top level, but accepting it under `fx:`
412
+ // keeps older drafts and long configs working.
413
+ if (raw.vendorDir != null) {
414
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
415
+ throw new Error(`${configFilename()}: vendorDir must be a path`);
416
+ fx.vendorDir = raw.vendorDir.trim();
417
+ }
301
418
  return {
302
419
  project: normalizeProject(raw.project || {}),
303
- fx: normalizeFx(raw.fx || {}),
420
+ fx,
304
421
  package: normalizePackage(raw.package || {}),
422
+ vendor: normalizeVendor(raw.vendor || {}),
423
+ vendorDir: fx.vendorDir,
305
424
  raw,
306
425
  };
307
426
  }
@@ -339,14 +458,26 @@ export function loadConfigFrom(dir) {
339
458
  project,
340
459
  fx,
341
460
  package: pkg,
461
+ vendor,
462
+ vendorDir,
342
463
  raw,
343
464
  } = parseConfig(fs.readFileSync(found, "utf8"));
344
- return { project, fx, package: pkg, raw, path: found };
465
+ return {
466
+ project,
467
+ fx,
468
+ package: pkg,
469
+ vendor,
470
+ vendorDir,
471
+ raw,
472
+ path: found,
473
+ };
345
474
  }
346
475
  return {
347
476
  project: { ...DEFAULT_PROJECT },
348
477
  fx: { ...DEFAULT_FX },
349
478
  package: normalizePackage({}),
479
+ vendor: [],
480
+ vendorDir: DEFAULT_VENDOR_DIR,
350
481
  raw: {},
351
482
  path: null,
352
483
  };
@@ -394,7 +525,10 @@ export function configComment(project) {
394
525
 
395
526
  export {
396
527
  addProjectExtension,
528
+ addVendorEntry,
397
529
  extensionKey,
398
530
  readProjectExtensions,
399
531
  removeProjectExtension,
532
+ removeProjectExtensions,
533
+ removeVendorEntry,
400
534
  } from "./config-edit.js";
package/src/extensions.js CHANGED
@@ -757,6 +757,7 @@ const BASE64_PAYLOAD = /^[A-Za-z0-9+/=\s]*$/;
757
757
  * @param {string} source
758
758
  * @returns {string}
759
759
  */
760
+ /** Encode extension source as a self-contained JavaScript data URI. */
760
761
  export function extensionDataUri(source) {
761
762
  const base64 = Buffer.from(String(source), "utf8").toString("base64");
762
763
  return `data:text/javascript;base64,${base64}`;
@@ -782,8 +783,13 @@ export function readExtensionDataUri(uri) {
782
783
  }
783
784
  }
784
785
 
785
- /** Resolve a config entry to source text. */
786
- async function resolveSource(entry, baseDir, fetchImpl) {
786
+ /**
787
+ * Resolve a config entry to source text and the original bytes.
788
+ *
789
+ * The raw buffer is retained so vendoring can lock exactly what the server
790
+ * returned, even when the text decoder strips a byte-order mark.
791
+ */
792
+ export async function resolveSource(entry, baseDir, fetchImpl) {
787
793
  const value = String(entry);
788
794
  if (/^data:/i.test(value)) {
789
795
  const source = readExtensionDataUri(value);
@@ -791,7 +797,13 @@ async function resolveSource(entry, baseDir, fetchImpl) {
791
797
  throw new Error(
792
798
  "unsupported data URI; expected base64 or percent-encoded JavaScript",
793
799
  );
794
- return { source, remote: false, dataUri: true, url: value };
800
+ return {
801
+ source,
802
+ buffer: Buffer.from(source, "utf8"),
803
+ remote: false,
804
+ dataUri: true,
805
+ url: value,
806
+ };
795
807
  }
796
808
  if (/^https?:\/\//i.test(value)) {
797
809
  const fetchFn = fetchImpl || globalThis.fetch;
@@ -800,13 +812,24 @@ async function resolveSource(entry, baseDir, fetchImpl) {
800
812
  const response = await fetchFn(value);
801
813
  if (!response.ok)
802
814
  throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
803
- return { source: await response.text(), remote: true, url: value };
815
+ const buffer =
816
+ typeof response.arrayBuffer === "function"
817
+ ? Buffer.from(await response.arrayBuffer())
818
+ : Buffer.from(await response.text(), "utf8");
819
+ return {
820
+ source: buffer.toString("utf8").replace(/^\uFEFF/, ""),
821
+ buffer,
822
+ remote: true,
823
+ url: value,
824
+ };
804
825
  }
805
826
  const resolved = path.isAbsolute(value)
806
827
  ? value
807
828
  : path.resolve(baseDir, value);
829
+ const buffer = fs.readFileSync(resolved);
808
830
  return {
809
- source: fs.readFileSync(resolved, "utf8"),
831
+ source: buffer.toString("utf8"),
832
+ buffer,
810
833
  remote: false,
811
834
  url: value,
812
835
  file: resolved,
@@ -850,19 +873,39 @@ async function evaluateExtensionAny(source, filename) {
850
873
  }
851
874
  }
852
875
 
876
+ /**
877
+ * Inspect extension source without running it in the Node.js process.
878
+ * Useful to propose an extension id while vendoring or migrating.
879
+ * @param {string} source
880
+ * @param {string} [filename]
881
+ * @returns {Promise<{infos: object[], unsandboxed: boolean}>}
882
+ */
883
+ export async function inspectExtension(source, filename = "extension") {
884
+ return evaluateExtensionAny(source, filename);
885
+ }
886
+
853
887
  /**
854
888
  * Load a list of extension references.
855
889
  *
856
890
  * @param {Array<string>} list Paths, `http(s)://` URLs or `data:` URIs.
857
- * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]}} [options]
891
+ * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[], reportUnmatchedUnsandboxed?: boolean}} [options]
858
892
  * `expectUnsandboxed` acknowledges extensions that are known to require
859
- * unsandboxed mode, silencing the warning for them.
860
- * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
893
+ * unsandboxed mode, silencing the warning for them. Set
894
+ * `reportUnmatchedUnsandboxed` to false when a caller combines declarations
895
+ * from several loaders and reports unmatched names itself.
896
+ * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[], matchedUnsandboxed: string[], unnecessaryUnsandboxed: string[]}>}
861
897
  * `unsandboxed` holds the ids of extensions that only load unsandboxed.
898
+ * `matchedUnsandboxed` and `unnecessaryUnsandboxed` let callers combine
899
+ * declarations across multiple loaders without double-reporting them.
862
900
  */
863
901
  export async function loadExtensions(
864
902
  list,
865
- { baseDir = process.cwd(), fetchImpl, expectUnsandboxed = [] } = {},
903
+ {
904
+ baseDir = process.cwd(),
905
+ fetchImpl,
906
+ expectUnsandboxed = [],
907
+ reportUnmatchedUnsandboxed = true,
908
+ } = {},
866
909
  ) {
867
910
  const catalog = {};
868
911
  const extensionUrls = {};
@@ -941,17 +984,27 @@ export async function loadExtensions(
941
984
  "project.unsandboxed in fx.yml once you trust it)",
942
985
  );
943
986
  }
944
- for (const name of declared) {
945
- if (declaredNotNeeded.has(name)) {
946
- warnings.push(
947
- `project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
948
- );
949
- } else if (!declaredSeen.has(name)) {
950
- warnings.push(
951
- `project.unsandboxed entry "${name}" does not match any loaded extension`,
952
- );
987
+ if (reportUnmatchedUnsandboxed) {
988
+ for (const name of declared) {
989
+ if (declaredNotNeeded.has(name)) {
990
+ warnings.push(
991
+ `project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
992
+ );
993
+ } else if (!declaredSeen.has(name)) {
994
+ warnings.push(
995
+ `project.unsandboxed entry "${name}" does not match any loaded extension`,
996
+ );
997
+ }
953
998
  }
954
999
  }
955
1000
 
956
- return { catalog, extensionUrls, warnings, ids, unsandboxed };
1001
+ return {
1002
+ catalog,
1003
+ extensionUrls,
1004
+ warnings,
1005
+ ids,
1006
+ unsandboxed,
1007
+ matchedUnsandboxed: [...declaredSeen],
1008
+ unnecessaryUnsandboxed: [...declaredNotNeeded],
1009
+ };
957
1010
  }
package/src/index.js CHANGED
@@ -29,6 +29,27 @@ export {
29
29
  resolvePackageConfig,
30
30
  slugify,
31
31
  } from "./package.js";
32
+ export {
33
+ LOCK_FILENAME,
34
+ VendorError,
35
+ defaultVendorName,
36
+ fetchVendorSource,
37
+ formatDiff,
38
+ installVendor,
39
+ installVendorEntry,
40
+ loadVendorExtensions,
41
+ migrateProjectExtensions,
42
+ outdatedVendor,
43
+ readLock,
44
+ removeVendoredExtension,
45
+ resolveVendorFiles,
46
+ sha256,
47
+ uniqueVendorName,
48
+ updateVendor,
49
+ vendorDirPath,
50
+ verifyVendor,
51
+ writeLock,
52
+ } from "./vendor.js";
32
53
  export { formatDevUrls, injectDevClient, startDevServer } from "./dev.js";
33
54
  export * from "./catalog.js";
34
55
  export * from "./config.js";