@triflare/fx 2.0.0 → 2.1.0-beta.2

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.
@@ -0,0 +1,425 @@
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 {
16
+ addFeatureEntry,
17
+ readFxValue,
18
+ removeFeatureEntry,
19
+ setFxScalar,
20
+ } from "./config-edit.js";
21
+ import { loadExtensions } from "./extensions.js";
22
+ import { formatDiff } from "./vendor.js";
23
+
24
+ export class FeatureError extends Error {
25
+ constructor(message, options) {
26
+ super(message, options);
27
+ this.name = "FeatureError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Feature registry. `source` points at the fork that ships with Fx; `enable`
33
+ * copies it into the project so the project owns (and pins) the bytes.
34
+ */
35
+ export const FEATURES = {
36
+ temporaryvariables: {
37
+ name: "temporaryvariables",
38
+ title: "Temporary Variables",
39
+ summary: "Disposable per-thread and per-run variables.",
40
+ extensionId: "fxTempVars",
41
+ filename: "fx-tempvars.js",
42
+ source: fileURLToPath(
43
+ new URL("../vendor/tempvars/fx-tempvars.js", import.meta.url),
44
+ ),
45
+ },
46
+ };
47
+
48
+ /**
49
+ * Built-in language/compiler features. These have no extension source and no
50
+ * vendored copy: they live behind a single `fx.<name>` setting, so they are
51
+ * enabled and disabled without touching `features:`.
52
+ */
53
+ export const CORE_FEATURES = {
54
+ tests: {
55
+ name: "tests",
56
+ title: "Testing",
57
+ summary: "Write project tests and run them with `fx test`.",
58
+ core: true,
59
+ },
60
+ };
61
+
62
+ /** List feature names for help and error messages. */
63
+ export function featureNames() {
64
+ return [...Object.keys(FEATURES), ...Object.keys(CORE_FEATURES)].sort();
65
+ }
66
+
67
+ /** Resolve one feature by name. */
68
+ export function getFeature(name) {
69
+ const key = String(name ?? "");
70
+ const feature = FEATURES[key] ?? CORE_FEATURES[key];
71
+ if (!feature)
72
+ throw new FeatureError(
73
+ `Unknown feature ${JSON.stringify(key)}. Available features: ${featureNames().join(", ")}.`,
74
+ );
75
+ return feature;
76
+ }
77
+
78
+ /** Absolute path of the extension source Fx ships for a feature. */
79
+ export function featureSourcePath(name) {
80
+ return getFeature(name).source;
81
+ }
82
+
83
+ /** Read the packaged feature source. */
84
+ export function readFeatureSource(name) {
85
+ const feature = getFeature(name);
86
+ try {
87
+ return fs.readFileSync(feature.source);
88
+ } catch (error) {
89
+ throw new FeatureError(
90
+ `Could not read the bundled source for ${feature.title}: ${error.message}`,
91
+ { cause: error },
92
+ );
93
+ }
94
+ }
95
+
96
+ function posix(value) {
97
+ return String(value).replace(/\\/g, "/");
98
+ }
99
+
100
+ /** Relative, project-facing path with a leading `./`. */
101
+ function relativeProjectPath(configDir, file) {
102
+ const rel = posix(path.relative(configDir, file));
103
+ if (rel === "") return ".";
104
+ return rel.startsWith(".") ? rel : `./${rel}`;
105
+ }
106
+
107
+ /** True when `target` is strictly below `base`. Both paths are lexical. */
108
+ function isBelow(base, target) {
109
+ const rel = path.relative(base, target);
110
+ return (
111
+ rel !== "" &&
112
+ rel !== ".." &&
113
+ !rel.startsWith(`..${path.sep}`) &&
114
+ !path.isAbsolute(rel)
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Refuse paths that escape the project directory, including through a
120
+ * symlinked ancestor. The longest existing prefix is resolved, so a target
121
+ * that does not exist yet is checked against where it would be created.
122
+ */
123
+ function assertInside(configDir, target, label) {
124
+ const base = path.resolve(configDir);
125
+ const absolute = path.resolve(target);
126
+ const message = `Refusing to use ${label} ${JSON.stringify(absolute)}: it must be inside the project directory`;
127
+ if (!isBelow(base, absolute)) throw new FeatureError(message);
128
+
129
+ let probe = absolute;
130
+ while (!fs.existsSync(probe)) {
131
+ const parent = path.dirname(probe);
132
+ if (parent === probe) break;
133
+ probe = parent;
134
+ }
135
+
136
+ let baseReal;
137
+ let probeReal;
138
+ try {
139
+ baseReal = fs.realpathSync(base);
140
+ probeReal = fs.realpathSync(probe);
141
+ } catch (error) {
142
+ throw new FeatureError(message, { cause: error });
143
+ }
144
+ const rel = path.relative(baseReal, probeReal);
145
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
146
+ throw new FeatureError(
147
+ `${message} (it resolves outside the project through a symlink)`,
148
+ );
149
+ return absolute;
150
+ }
151
+
152
+ /** Replace a file atomically so an interrupted write cannot truncate it. */
153
+ function writeFileAtomic(file, data) {
154
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
155
+ try {
156
+ fs.writeFileSync(temp, data, { flag: "wx" });
157
+ fs.renameSync(temp, file);
158
+ } finally {
159
+ fs.rmSync(temp, { force: true });
160
+ }
161
+ }
162
+
163
+ /** Read the `features:` list from a config file. */
164
+ function readFeatureEntries(configPath) {
165
+ return parseConfig(fs.readFileSync(configPath, "utf8")).features;
166
+ }
167
+
168
+ /**
169
+ * Install a feature into a project: copy the packaged extension to the vendor
170
+ * directory and record the file under `features:`.
171
+ *
172
+ * @param {object} options
173
+ * @param {string} options.configPath
174
+ * @param {string} options.configDir
175
+ * @param {string} options.name
176
+ * @param {string} [options.vendorDir]
177
+ * @param {boolean} [options.force] replace a modified copy
178
+ * @returns {{feature: object, name: string, file: string, path: string, changed: boolean, configChanged: boolean}}
179
+ */
180
+ /** True when a feature's setting is enabled in the project config. */
181
+ function coreFeatureEnabled(configPath, feature) {
182
+ if (!configPath || !fs.existsSync(configPath)) return false;
183
+ return Boolean(readFxValue(configPath, feature.name));
184
+ }
185
+
186
+ /**
187
+ * Whether a feature is currently enabled in a project.
188
+ * @param {{configPath?: string, name: string}} options
189
+ */
190
+ export function featureEnabled({ configPath, name } = {}) {
191
+ const feature = getFeature(name);
192
+ if (feature.core) return coreFeatureEnabled(configPath, feature);
193
+ if (!configPath || !fs.existsSync(configPath)) return false;
194
+ return Boolean(
195
+ readFeatureEntries(configPath).find((entry) => entry.name === feature.name),
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Enable a feature in a project.
201
+ *
202
+ * Extension features copy their source into the project; core features only
203
+ * record `fx.<name>: true` in `fx.yml`.
204
+ *
205
+ * @param {object} options
206
+ * @param {string} options.configPath
207
+ * @param {string} options.configDir
208
+ * @param {string} options.name
209
+ * @param {string} [options.vendorDir]
210
+ * @param {boolean} [options.force] replace a modified copy
211
+ * @returns {{feature: object, name: string, file: string|null, path: string|null, changed: boolean, configChanged: boolean}}
212
+ */
213
+ export function enableFeature({
214
+ configPath,
215
+ configDir,
216
+ name,
217
+ vendorDir = "extensions",
218
+ force = false,
219
+ } = {}) {
220
+ const feature = getFeature(name);
221
+ if (feature.core) {
222
+ const changed = !coreFeatureEnabled(configPath, feature);
223
+ if (changed) setFxScalar(configPath, feature.name, true);
224
+ return {
225
+ feature,
226
+ name: feature.name,
227
+ file: null,
228
+ path: null,
229
+ changed,
230
+ configChanged: changed,
231
+ };
232
+ }
233
+ const dir = assertInside(
234
+ configDir,
235
+ path.resolve(configDir, String(vendorDir || "extensions")),
236
+ "feature directory",
237
+ );
238
+ const target = path.join(dir, feature.filename);
239
+ const source = readFeatureSource(feature.name);
240
+ const existing = fs.existsSync(target) ? fs.readFileSync(target) : null;
241
+ const changed = !existing || !existing.equals(source);
242
+ if (existing && changed && !force)
243
+ throw new FeatureError(
244
+ `${feature.title} is already installed at ${relativeProjectPath(
245
+ configDir,
246
+ target,
247
+ )} with different contents. Run "fx feature ${feature.name} update" to replace it.`,
248
+ );
249
+
250
+ if (changed) {
251
+ fs.mkdirSync(dir, { recursive: true });
252
+ writeFileAtomic(target, source);
253
+ }
254
+ const file = relativeProjectPath(configDir, target);
255
+ const configured = readFeatureEntries(configPath).find(
256
+ (entry) => entry.name === feature.name,
257
+ );
258
+ const needsEntry = !configured || configured.file !== file;
259
+ if (needsEntry) addFeatureEntry(configPath, feature.name, file);
260
+ return {
261
+ feature,
262
+ name: feature.name,
263
+ file,
264
+ path: target,
265
+ changed,
266
+ configChanged: needsEntry,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Remove a feature from `fx.yml` and delete the copy Fx installed.
272
+ *
273
+ * A file is deleted only when its name and bytes match the packaged feature
274
+ * source that `enable`/`update` write. A hand-pointed or edited project file is
275
+ * user-owned and survives, even though its `features:` entry is removed.
276
+ */
277
+ export function disableFeature({ configPath, configDir, name } = {}) {
278
+ const feature = getFeature(name);
279
+ if (feature.core) {
280
+ if (!coreFeatureEnabled(configPath, feature))
281
+ return {
282
+ feature,
283
+ name: feature.name,
284
+ file: null,
285
+ changed: false,
286
+ warning: null,
287
+ };
288
+ setFxScalar(configPath, feature.name, false);
289
+ return {
290
+ feature,
291
+ name: feature.name,
292
+ file: null,
293
+ changed: true,
294
+ warning: `${feature.title} will become a core feature in a future version.`,
295
+ };
296
+ }
297
+ const entry = readFeatureEntries(configPath).find(
298
+ (candidate) => candidate.name === feature.name,
299
+ );
300
+ if (!entry)
301
+ throw new FeatureError(
302
+ `${feature.title} is not enabled. Run "fx feature ${feature.name} enable" first.`,
303
+ );
304
+
305
+ const target = assertInside(
306
+ configDir,
307
+ path.resolve(configDir, entry.file),
308
+ "feature file",
309
+ );
310
+ const managedName = path.basename(target) === feature.filename;
311
+ const fileExists = managedName && fs.existsSync(target);
312
+ let installedByFx = false;
313
+ if (fileExists) {
314
+ try {
315
+ installedByFx = fs
316
+ .readFileSync(target)
317
+ .equals(readFeatureSource(feature.name));
318
+ } catch {
319
+ installedByFx = false;
320
+ }
321
+ }
322
+ removeFeatureEntry(configPath, feature.name);
323
+ if (installedByFx) fs.rmSync(target, { force: true });
324
+ return {
325
+ feature,
326
+ name: feature.name,
327
+ file: entry.file,
328
+ fileRemoved: installedByFx,
329
+ filePreserved: fileExists && !installedByFx,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Replace a project's feature copy with the one bundled in this Fx version.
335
+ * Nothing changes unless this command (or `enable --force`) runs.
336
+ */
337
+ export function updateFeature({
338
+ configPath,
339
+ configDir,
340
+ name,
341
+ dryRun = false,
342
+ } = {}) {
343
+ const feature = getFeature(name);
344
+ if (feature.core)
345
+ throw new FeatureError(
346
+ `${feature.title} is built into Fx, so there is no vendored source or lock entry to update.`,
347
+ );
348
+ const entry = readFeatureEntries(configPath).find(
349
+ (candidate) => candidate.name === feature.name,
350
+ );
351
+ if (!entry)
352
+ throw new FeatureError(
353
+ `${feature.title} is not enabled. Run "fx feature ${feature.name} enable" first.`,
354
+ );
355
+
356
+ const target = assertInside(
357
+ configDir,
358
+ path.resolve(configDir, entry.file),
359
+ "feature file",
360
+ );
361
+ const after = readFeatureSource(feature.name);
362
+ const before = fs.existsSync(target)
363
+ ? fs.readFileSync(target)
364
+ : Buffer.alloc(0);
365
+ if (before.equals(after))
366
+ return {
367
+ feature,
368
+ name: feature.name,
369
+ file: entry.file,
370
+ changed: false,
371
+ dryRun: Boolean(dryRun),
372
+ diff: "",
373
+ };
374
+
375
+ const diff = formatDiff(
376
+ before.toString("utf8"),
377
+ after.toString("utf8"),
378
+ entry.file,
379
+ );
380
+ if (!dryRun) {
381
+ fs.mkdirSync(path.dirname(target), { recursive: true });
382
+ writeFileAtomic(target, after);
383
+ }
384
+ return {
385
+ feature,
386
+ name: feature.name,
387
+ file: entry.file,
388
+ changed: true,
389
+ dryRun: Boolean(dryRun),
390
+ diff,
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Load every configured feature extension.
396
+ * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
397
+ */
398
+ export async function loadFeatureExtensions({ configDir, config } = {}) {
399
+ const catalog = {};
400
+ const extensionUrls = {};
401
+ const warnings = [];
402
+ const ids = [];
403
+ const unsandboxed = [];
404
+
405
+ for (const entry of config?.features || []) {
406
+ const absolute = path.resolve(configDir, entry.file);
407
+ if (!fs.existsSync(absolute)) {
408
+ warnings.push(
409
+ `feature "${entry.name}": ${entry.file} is missing; run "fx feature ${entry.name} enable"`,
410
+ );
411
+ continue;
412
+ }
413
+ const loaded = await loadExtensions([absolute], {
414
+ baseDir: configDir,
415
+ reportUnmatchedUnsandboxed: false,
416
+ });
417
+ Object.assign(catalog, loaded.catalog);
418
+ Object.assign(extensionUrls, loaded.extensionUrls);
419
+ warnings.push(...loaded.warnings);
420
+ ids.push(...loaded.ids);
421
+ unsandboxed.push(...loaded.unsandboxed);
422
+ }
423
+
424
+ return { catalog, extensionUrls, warnings, ids, unsandboxed };
425
+ }