@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/README.md +27 -0
- package/package.json +4 -2
- package/product.yml +1 -1
- package/src/catalog.js +103 -0
- package/src/cli/commands/feature.js +114 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +6 -0
- package/src/cli/commands/lint.js +270 -0
- package/src/cli/context.js +13 -0
- package/src/cli/define-command.js +8 -1
- package/src/cli/reporter.js +9 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +119 -0
- package/src/config.js +37 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +339 -0
- package/src/formatter.js +499 -0
- package/src/index.js +10 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1414 -0
- package/src/lint/config-file.js +97 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +109 -0
- package/src/parser.js +53 -13
- package/src/project.js +7 -87
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
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)
|
package/src/features.js
ADDED
|
@@ -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
|
+
}
|