@triflare/fx 2.0.0 → 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.
package/src/config.js CHANGED
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import * as yaml from "js-yaml";
13
13
  import { configFilename, configFilenames, productName } from "./brand.js";
14
+ import { DEFAULT_LINT, normalizeLintConfig } from "./lint/config.js";
14
15
 
15
16
  export const DEFAULT_PROJECT = {
16
17
  name: `${productName()} Project`,
@@ -32,6 +33,7 @@ export const DEFAULT_PROJECT = {
32
33
  export const DEFAULT_FX = {
33
34
  partialSprites: false,
34
35
  vendorDir: "extensions",
36
+ lint: { ...DEFAULT_LINT, rules: { ...DEFAULT_LINT.rules } },
35
37
  };
36
38
 
37
39
  /** Default directory that receives vendored extension sources. */
@@ -121,6 +123,32 @@ export function normalizeVendorEntry(name, raw) {
121
123
  };
122
124
  }
123
125
 
126
+ /**
127
+ * Normalize the top-level `features:` mapping. Values are file paths (or
128
+ * `{ file }` mappings), relative to the config directory.
129
+ *
130
+ * @param {unknown} raw
131
+ * @returns {Array<{name: string, file: string}>}
132
+ */
133
+ export function normalizeFeatures(raw = {}) {
134
+ if (raw == null) return [];
135
+ if (!isPlainObject(raw))
136
+ throw new Error(`${configFilename()}: "features" must be a mapping`);
137
+ return Object.entries(raw).map(([name, value]) => {
138
+ const file =
139
+ typeof value === "string"
140
+ ? value
141
+ : isPlainObject(value) && typeof value.file === "string"
142
+ ? value.file
143
+ : null;
144
+ if (!file || file.trim() === "")
145
+ throw new Error(
146
+ `${configFilename()}: features.${name} must be a file path or { file: "<path>" }`,
147
+ );
148
+ return { name: String(name), file: file.trim() };
149
+ });
150
+ }
151
+
124
152
  /**
125
153
  * Normalize the top-level `vendor:` mapping. Order is load order, so this is an
126
154
  * array rather than a plain object.
@@ -381,7 +409,7 @@ export function normalizeProject(raw = {}) {
381
409
  * @returns {typeof DEFAULT_FX}
382
410
  */
383
411
  export function normalizeFx(raw = {}) {
384
- const fx = { ...DEFAULT_FX };
412
+ const fx = { ...DEFAULT_FX, lint: { ...DEFAULT_LINT, rules: {} } };
385
413
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
386
414
  if (raw.partialSprites != null)
387
415
  fx.partialSprites = Boolean(raw.partialSprites);
@@ -390,6 +418,10 @@ export function normalizeFx(raw = {}) {
390
418
  throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
391
419
  fx.vendorDir = raw.vendorDir.trim();
392
420
  }
421
+ fx.lint = normalizeLintConfig(
422
+ raw.lint,
423
+ (message) => new Error(`${configFilename()}: ${message}`),
424
+ );
393
425
  }
394
426
  return fx;
395
427
  }
@@ -420,6 +452,7 @@ export function parseConfig(text) {
420
452
  fx,
421
453
  package: normalizePackage(raw.package || {}),
422
454
  vendor: normalizeVendor(raw.vendor || {}),
455
+ features: normalizeFeatures(raw.features || {}),
423
456
  vendorDir: fx.vendorDir,
424
457
  raw,
425
458
  };
@@ -459,6 +492,7 @@ export function loadConfigFrom(dir) {
459
492
  fx,
460
493
  package: pkg,
461
494
  vendor,
495
+ features,
462
496
  vendorDir,
463
497
  raw,
464
498
  } = parseConfig(fs.readFileSync(found, "utf8"));
@@ -467,6 +501,7 @@ export function loadConfigFrom(dir) {
467
501
  fx,
468
502
  package: pkg,
469
503
  vendor,
504
+ features,
470
505
  vendorDir,
471
506
  raw,
472
507
  path: found,
@@ -477,6 +512,7 @@ export function loadConfigFrom(dir) {
477
512
  fx: { ...DEFAULT_FX },
478
513
  package: normalizePackage({}),
479
514
  vendor: [],
515
+ features: [],
480
516
  vendorDir: DEFAULT_VENDOR_DIR,
481
517
  raw: {},
482
518
  path: null,
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)
@@ -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
+ }