@triflare/fx 1.0.3 → 2.1.0-beta.1

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 (68) hide show
  1. package/README.md +59 -0
  2. package/bin/fx.js +4 -1162
  3. package/package.json +8 -4
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/catalog.js +103 -0
  7. package/src/cli/commands/build.js +98 -0
  8. package/src/cli/commands/catalog.js +42 -0
  9. package/src/cli/commands/check.js +44 -0
  10. package/src/cli/commands/completion.js +54 -0
  11. package/src/cli/commands/decompile.js +266 -0
  12. package/src/cli/commands/dev.js +179 -0
  13. package/src/cli/commands/extension.js +110 -0
  14. package/src/cli/commands/feature.js +114 -0
  15. package/src/cli/commands/fmt.js +173 -0
  16. package/src/cli/commands/index.js +45 -0
  17. package/src/cli/commands/init.js +169 -0
  18. package/src/cli/commands/lint.js +270 -0
  19. package/src/cli/commands/package.js +137 -0
  20. package/src/cli/commands/sprite/add.js +85 -0
  21. package/src/cli/commands/sprite/build.js +62 -0
  22. package/src/cli/commands/sprite/helpers.js +66 -0
  23. package/src/cli/commands/sprite/index.js +29 -0
  24. package/src/cli/commands/sprite/list.js +31 -0
  25. package/src/cli/commands/sprite/remove.js +51 -0
  26. package/src/cli/commands/vendor/add.js +98 -0
  27. package/src/cli/commands/vendor/helpers.js +50 -0
  28. package/src/cli/commands/vendor/index.js +39 -0
  29. package/src/cli/commands/vendor/install.js +64 -0
  30. package/src/cli/commands/vendor/list.js +45 -0
  31. package/src/cli/commands/vendor/migrate.js +47 -0
  32. package/src/cli/commands/vendor/outdated.js +51 -0
  33. package/src/cli/commands/vendor/remove.js +34 -0
  34. package/src/cli/commands/vendor/update.js +49 -0
  35. package/src/cli/commands/vendor/verify.js +44 -0
  36. package/src/cli/completion/bash.js +10 -0
  37. package/src/cli/completion/fish.js +9 -0
  38. package/src/cli/completion/index.js +97 -0
  39. package/src/cli/completion/zsh.js +11 -0
  40. package/src/cli/context.js +270 -0
  41. package/src/cli/define-command.js +147 -0
  42. package/src/cli/errors.js +111 -0
  43. package/src/cli/format.js +31 -0
  44. package/src/cli/prompts.js +183 -0
  45. package/src/cli/reporter.js +203 -0
  46. package/src/cli/run.js +248 -0
  47. package/src/cli/templates/index.js +112 -0
  48. package/src/cli/theme.js +84 -0
  49. package/src/compiler.js +133 -0
  50. package/src/config-edit.js +399 -1
  51. package/src/config.js +174 -4
  52. package/src/decompiler.js +45 -0
  53. package/src/extensions.js +72 -19
  54. package/src/features.js +339 -0
  55. package/src/formatter.js +499 -0
  56. package/src/index.js +31 -0
  57. package/src/lexer.js +45 -4
  58. package/src/lint/analyze.js +1414 -0
  59. package/src/lint/config-file.js +97 -0
  60. package/src/lint/config.js +174 -0
  61. package/src/lint/rules.js +461 -0
  62. package/src/linter.js +220 -0
  63. package/src/merge.js +109 -0
  64. package/src/parser.js +53 -13
  65. package/src/project.js +7 -87
  66. package/src/vendor.js +1323 -0
  67. package/vendor/pm-packager-dist/.fx-build.json +1 -1
  68. package/vendor/tempvars/fx-tempvars.js +321 -0
package/src/decompiler.js CHANGED
@@ -49,6 +49,8 @@ const LIST_OPCODE_METHOD = {
49
49
  data_listcontainsitem: { method: "contains", inputs: ["ITEM"] },
50
50
  };
51
51
 
52
+ const SAFE_TEMP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
53
+
52
54
  function escapeString(value) {
53
55
  return String(value)
54
56
  .replace(/\\/g, "\\\\")
@@ -172,6 +174,14 @@ class Decompiler {
172
174
  }
173
175
  if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
174
176
  if (block.opcode === "data_listcontents") return block.fields.LIST[0];
177
+ if (block.opcode === "fxTempVars_getThreadVariable") {
178
+ const name = this.tempVarName(block);
179
+ if (name != null && SAFE_TEMP_NAME.test(name)) return `$${name}`;
180
+ }
181
+ if (block.opcode === "fxTempVars_getRuntimeVariable") {
182
+ const name = this.tempVarName(block);
183
+ if (name != null && SAFE_TEMP_NAME.test(name)) return `$$${name}`;
184
+ }
175
185
  if (
176
186
  Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
177
187
  ) {
@@ -254,6 +264,27 @@ class Decompiler {
254
264
  return lines.filter(Boolean).join("\n");
255
265
  }
256
266
 
267
+ /** Literal name stored in a temporary-variable block's VAR input. */
268
+ tempVarName(block) {
269
+ const raw = (block.inputs || {}).VAR;
270
+ const inner = Array.isArray(raw) ? raw[1] : null;
271
+ if (!Array.isArray(inner) || ![4, 5, 6, 7, 8, 9, 10, 11].includes(inner[0]))
272
+ return null;
273
+ return inner[1] == null ? null : String(inner[1]);
274
+ }
275
+
276
+ /** Render set/change on a temporary variable, with sugar when the name is safe. */
277
+ renderTempVarAssign(block, pad, prefix, op, catalogName) {
278
+ const name = this.tempVarName(block);
279
+ const value = this.renderExpression(
280
+ block.inputs.STRING ?? block.inputs.NUM,
281
+ );
282
+ if (name != null && SAFE_TEMP_NAME.test(name))
283
+ return `${pad}${prefix}${name} ${op} ${value};`;
284
+ const input = this.renderExpression(block.inputs.VAR);
285
+ return `${pad}tempvars.${catalogName}(${input}, ${value});`;
286
+ }
287
+
257
288
  renderStatement(block, indent) {
258
289
  const pad = " ".repeat(indent);
259
290
  const sub = (firstId) => {
@@ -289,6 +320,20 @@ class Decompiler {
289
320
  return `${pad}${this.renderProcedureCall(block)};`;
290
321
  case "control_stop":
291
322
  return `${pad}control.stop("${block.fields.STOP_OPTION[0]}");`;
323
+ case "fxTempVars_setThreadVariable":
324
+ return this.renderTempVarAssign(block, pad, "$", "=", "setThread");
325
+ case "fxTempVars_changeThreadVariable":
326
+ return this.renderTempVarAssign(block, pad, "$", "+=", "changeThread");
327
+ case "fxTempVars_setRuntimeVariable":
328
+ return this.renderTempVarAssign(block, pad, "$$", "=", "setRuntime");
329
+ case "fxTempVars_changeRuntimeVariable":
330
+ return this.renderTempVarAssign(
331
+ block,
332
+ pad,
333
+ "$$",
334
+ "+=",
335
+ "changeRuntime",
336
+ );
292
337
  default: {
293
338
  if (
294
339
  Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
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
  }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Built-in, project-local features.
3
+ *
4
+ * `fx feature <name> enable` copies a feature's extension source into the
5
+ * project and records that file under `features:` in `fx.yml`. Builds load the
6
+ * committed file, so an Fx upgrade never changes a project's feature code, and
7
+ * the project keeps working offline (and in Fx versions that predate the
8
+ * feature system) because the extension is an ordinary JavaScript file.
9
+ */
10
+ import crypto from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { parseConfig } from "./config.js";
15
+ import { addFeatureEntry, removeFeatureEntry } from "./config-edit.js";
16
+ import { loadExtensions } from "./extensions.js";
17
+ import { formatDiff } from "./vendor.js";
18
+
19
+ export class FeatureError extends Error {
20
+ constructor(message, options) {
21
+ super(message, options);
22
+ this.name = "FeatureError";
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Feature registry. `source` points at the fork that ships with Fx; `enable`
28
+ * copies it into the project so the project owns (and pins) the bytes.
29
+ */
30
+ export const FEATURES = {
31
+ temporaryvariables: {
32
+ name: "temporaryvariables",
33
+ title: "Temporary Variables",
34
+ summary: "Disposable per-thread and per-run variables.",
35
+ extensionId: "fxTempVars",
36
+ filename: "fx-tempvars.js",
37
+ source: fileURLToPath(
38
+ new URL("../vendor/tempvars/fx-tempvars.js", import.meta.url),
39
+ ),
40
+ },
41
+ };
42
+
43
+ /** List feature names for help and error messages. */
44
+ export function featureNames() {
45
+ return Object.keys(FEATURES).sort();
46
+ }
47
+
48
+ /** Resolve one feature by name. */
49
+ export function getFeature(name) {
50
+ const key = String(name ?? "");
51
+ const feature = FEATURES[key];
52
+ if (!feature)
53
+ throw new FeatureError(
54
+ `Unknown feature ${JSON.stringify(key)}. Available features: ${featureNames().join(", ")}.`,
55
+ );
56
+ return feature;
57
+ }
58
+
59
+ /** Absolute path of the extension source Fx ships for a feature. */
60
+ export function featureSourcePath(name) {
61
+ return getFeature(name).source;
62
+ }
63
+
64
+ /** Read the packaged feature source. */
65
+ export function readFeatureSource(name) {
66
+ const feature = getFeature(name);
67
+ try {
68
+ return fs.readFileSync(feature.source);
69
+ } catch (error) {
70
+ throw new FeatureError(
71
+ `Could not read the bundled source for ${feature.title}: ${error.message}`,
72
+ { cause: error },
73
+ );
74
+ }
75
+ }
76
+
77
+ function posix(value) {
78
+ return String(value).replace(/\\/g, "/");
79
+ }
80
+
81
+ /** Relative, project-facing path with a leading `./`. */
82
+ function relativeProjectPath(configDir, file) {
83
+ const rel = posix(path.relative(configDir, file));
84
+ if (rel === "") return ".";
85
+ return rel.startsWith(".") ? rel : `./${rel}`;
86
+ }
87
+
88
+ /** True when `target` is strictly below `base`. Both paths are lexical. */
89
+ function isBelow(base, target) {
90
+ const rel = path.relative(base, target);
91
+ return (
92
+ rel !== "" &&
93
+ rel !== ".." &&
94
+ !rel.startsWith(`..${path.sep}`) &&
95
+ !path.isAbsolute(rel)
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Refuse paths that escape the project directory, including through a
101
+ * symlinked ancestor. The longest existing prefix is resolved, so a target
102
+ * that does not exist yet is checked against where it would be created.
103
+ */
104
+ function assertInside(configDir, target, label) {
105
+ const base = path.resolve(configDir);
106
+ const absolute = path.resolve(target);
107
+ const message = `Refusing to use ${label} ${JSON.stringify(absolute)}: it must be inside the project directory`;
108
+ if (!isBelow(base, absolute)) throw new FeatureError(message);
109
+
110
+ let probe = absolute;
111
+ while (!fs.existsSync(probe)) {
112
+ const parent = path.dirname(probe);
113
+ if (parent === probe) break;
114
+ probe = parent;
115
+ }
116
+
117
+ let baseReal;
118
+ let probeReal;
119
+ try {
120
+ baseReal = fs.realpathSync(base);
121
+ probeReal = fs.realpathSync(probe);
122
+ } catch (error) {
123
+ throw new FeatureError(message, { cause: error });
124
+ }
125
+ const rel = path.relative(baseReal, probeReal);
126
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
127
+ throw new FeatureError(
128
+ `${message} (it resolves outside the project through a symlink)`,
129
+ );
130
+ return absolute;
131
+ }
132
+
133
+ /** Replace a file atomically so an interrupted write cannot truncate it. */
134
+ function writeFileAtomic(file, data) {
135
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
136
+ try {
137
+ fs.writeFileSync(temp, data, { flag: "wx" });
138
+ fs.renameSync(temp, file);
139
+ } finally {
140
+ fs.rmSync(temp, { force: true });
141
+ }
142
+ }
143
+
144
+ /** Read the `features:` list from a config file. */
145
+ function readFeatureEntries(configPath) {
146
+ return parseConfig(fs.readFileSync(configPath, "utf8")).features;
147
+ }
148
+
149
+ /**
150
+ * Install a feature into a project: copy the packaged extension to the vendor
151
+ * directory and record the file under `features:`.
152
+ *
153
+ * @param {object} options
154
+ * @param {string} options.configPath
155
+ * @param {string} options.configDir
156
+ * @param {string} options.name
157
+ * @param {string} [options.vendorDir]
158
+ * @param {boolean} [options.force] replace a modified copy
159
+ * @returns {{feature: object, name: string, file: string, path: string, changed: boolean, configChanged: boolean}}
160
+ */
161
+ export function enableFeature({
162
+ configPath,
163
+ configDir,
164
+ name,
165
+ vendorDir = "extensions",
166
+ force = false,
167
+ } = {}) {
168
+ const feature = getFeature(name);
169
+ const dir = assertInside(
170
+ configDir,
171
+ path.resolve(configDir, String(vendorDir || "extensions")),
172
+ "feature directory",
173
+ );
174
+ const target = path.join(dir, feature.filename);
175
+ const source = readFeatureSource(feature.name);
176
+ const existing = fs.existsSync(target) ? fs.readFileSync(target) : null;
177
+ const changed = !existing || !existing.equals(source);
178
+ if (existing && changed && !force)
179
+ throw new FeatureError(
180
+ `${feature.title} is already installed at ${relativeProjectPath(
181
+ configDir,
182
+ target,
183
+ )} with different contents. Run "fx feature ${feature.name} update" to replace it.`,
184
+ );
185
+
186
+ if (changed) {
187
+ fs.mkdirSync(dir, { recursive: true });
188
+ writeFileAtomic(target, source);
189
+ }
190
+ const file = relativeProjectPath(configDir, target);
191
+ const configured = readFeatureEntries(configPath).find(
192
+ (entry) => entry.name === feature.name,
193
+ );
194
+ const needsEntry = !configured || configured.file !== file;
195
+ if (needsEntry) addFeatureEntry(configPath, feature.name, file);
196
+ return {
197
+ feature,
198
+ name: feature.name,
199
+ file,
200
+ path: target,
201
+ changed,
202
+ configChanged: needsEntry,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Remove a feature from `fx.yml` and delete the copy Fx installed.
208
+ *
209
+ * A file is deleted only when its name and bytes match the packaged feature
210
+ * source that `enable`/`update` write. A hand-pointed or edited project file is
211
+ * user-owned and survives, even though its `features:` entry is removed.
212
+ */
213
+ export function disableFeature({ configPath, configDir, name } = {}) {
214
+ const feature = getFeature(name);
215
+ const entry = readFeatureEntries(configPath).find(
216
+ (candidate) => candidate.name === feature.name,
217
+ );
218
+ if (!entry)
219
+ throw new FeatureError(
220
+ `${feature.title} is not enabled. Run "fx feature ${feature.name} enable" first.`,
221
+ );
222
+
223
+ const target = assertInside(
224
+ configDir,
225
+ path.resolve(configDir, entry.file),
226
+ "feature file",
227
+ );
228
+ const managedName = path.basename(target) === feature.filename;
229
+ const fileExists = managedName && fs.existsSync(target);
230
+ let installedByFx = false;
231
+ if (fileExists) {
232
+ try {
233
+ installedByFx = fs
234
+ .readFileSync(target)
235
+ .equals(readFeatureSource(feature.name));
236
+ } catch {
237
+ installedByFx = false;
238
+ }
239
+ }
240
+ removeFeatureEntry(configPath, feature.name);
241
+ if (installedByFx) fs.rmSync(target, { force: true });
242
+ return {
243
+ feature,
244
+ name: feature.name,
245
+ file: entry.file,
246
+ fileRemoved: installedByFx,
247
+ filePreserved: fileExists && !installedByFx,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Replace a project's feature copy with the one bundled in this Fx version.
253
+ * Nothing changes unless this command (or `enable --force`) runs.
254
+ */
255
+ export function updateFeature({
256
+ configPath,
257
+ configDir,
258
+ name,
259
+ dryRun = false,
260
+ } = {}) {
261
+ const feature = getFeature(name);
262
+ const entry = readFeatureEntries(configPath).find(
263
+ (candidate) => candidate.name === feature.name,
264
+ );
265
+ if (!entry)
266
+ throw new FeatureError(
267
+ `${feature.title} is not enabled. Run "fx feature ${feature.name} enable" first.`,
268
+ );
269
+
270
+ const target = assertInside(
271
+ configDir,
272
+ path.resolve(configDir, entry.file),
273
+ "feature file",
274
+ );
275
+ const after = readFeatureSource(feature.name);
276
+ const before = fs.existsSync(target)
277
+ ? fs.readFileSync(target)
278
+ : Buffer.alloc(0);
279
+ if (before.equals(after))
280
+ return {
281
+ feature,
282
+ name: feature.name,
283
+ file: entry.file,
284
+ changed: false,
285
+ dryRun: Boolean(dryRun),
286
+ diff: "",
287
+ };
288
+
289
+ const diff = formatDiff(
290
+ before.toString("utf8"),
291
+ after.toString("utf8"),
292
+ entry.file,
293
+ );
294
+ if (!dryRun) {
295
+ fs.mkdirSync(path.dirname(target), { recursive: true });
296
+ writeFileAtomic(target, after);
297
+ }
298
+ return {
299
+ feature,
300
+ name: feature.name,
301
+ file: entry.file,
302
+ changed: true,
303
+ dryRun: Boolean(dryRun),
304
+ diff,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Load every configured feature extension.
310
+ * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
311
+ */
312
+ export async function loadFeatureExtensions({ configDir, config } = {}) {
313
+ const catalog = {};
314
+ const extensionUrls = {};
315
+ const warnings = [];
316
+ const ids = [];
317
+ const unsandboxed = [];
318
+
319
+ for (const entry of config?.features || []) {
320
+ const absolute = path.resolve(configDir, entry.file);
321
+ if (!fs.existsSync(absolute)) {
322
+ warnings.push(
323
+ `feature "${entry.name}": ${entry.file} is missing; run "fx feature ${entry.name} enable"`,
324
+ );
325
+ continue;
326
+ }
327
+ const loaded = await loadExtensions([absolute], {
328
+ baseDir: configDir,
329
+ reportUnmatchedUnsandboxed: false,
330
+ });
331
+ Object.assign(catalog, loaded.catalog);
332
+ Object.assign(extensionUrls, loaded.extensionUrls);
333
+ warnings.push(...loaded.warnings);
334
+ ids.push(...loaded.ids);
335
+ unsandboxed.push(...loaded.unsandboxed);
336
+ }
337
+
338
+ return { catalog, extensionUrls, warnings, ids, unsandboxed };
339
+ }