@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.
- package/README.md +59 -0
- package/bin/fx.js +4 -1162
- package/package.json +8 -4
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/catalog.js +103 -0
- package/src/cli/commands/build.js +98 -0
- package/src/cli/commands/catalog.js +42 -0
- package/src/cli/commands/check.js +44 -0
- package/src/cli/commands/completion.js +54 -0
- package/src/cli/commands/decompile.js +266 -0
- package/src/cli/commands/dev.js +179 -0
- package/src/cli/commands/extension.js +110 -0
- package/src/cli/commands/feature.js +114 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +45 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/lint.js +270 -0
- package/src/cli/commands/package.js +137 -0
- package/src/cli/commands/sprite/add.js +85 -0
- package/src/cli/commands/sprite/build.js +62 -0
- package/src/cli/commands/sprite/helpers.js +66 -0
- package/src/cli/commands/sprite/index.js +29 -0
- package/src/cli/commands/sprite/list.js +31 -0
- package/src/cli/commands/sprite/remove.js +51 -0
- package/src/cli/commands/vendor/add.js +98 -0
- package/src/cli/commands/vendor/helpers.js +50 -0
- package/src/cli/commands/vendor/index.js +39 -0
- package/src/cli/commands/vendor/install.js +64 -0
- package/src/cli/commands/vendor/list.js +45 -0
- package/src/cli/commands/vendor/migrate.js +47 -0
- package/src/cli/commands/vendor/outdated.js +51 -0
- package/src/cli/commands/vendor/remove.js +34 -0
- package/src/cli/commands/vendor/update.js +49 -0
- package/src/cli/commands/vendor/verify.js +44 -0
- package/src/cli/completion/bash.js +10 -0
- package/src/cli/completion/fish.js +9 -0
- package/src/cli/completion/index.js +97 -0
- package/src/cli/completion/zsh.js +11 -0
- package/src/cli/context.js +270 -0
- package/src/cli/define-command.js +147 -0
- package/src/cli/errors.js +111 -0
- package/src/cli/format.js +31 -0
- package/src/cli/prompts.js +183 -0
- package/src/cli/reporter.js +203 -0
- package/src/cli/run.js +248 -0
- package/src/cli/templates/index.js +112 -0
- package/src/cli/theme.js +84 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +399 -1
- package/src/config.js +174 -4
- package/src/decompiler.js +45 -0
- package/src/extensions.js +72 -19
- package/src/features.js +339 -0
- package/src/formatter.js +499 -0
- package/src/index.js +31 -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/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { decompileProject } from "../../decompiler.js";
|
|
4
|
+
import { unzipSync } from "../../sb3.js";
|
|
5
|
+
import { addProjectExtension } from "../../config.js";
|
|
6
|
+
import { configFilename, configFilenames } from "../../brand.js";
|
|
7
|
+
import { loadExtensions, readExtensionDataUri } from "../../extensions.js";
|
|
8
|
+
import { truncate } from "../format.js";
|
|
9
|
+
import { UsageError } from "../errors.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recover the extensions a decompiled project references.
|
|
13
|
+
*
|
|
14
|
+
* An archive only remembers extension sources through `project.extensionURLs`:
|
|
15
|
+
* Fx stores local extension files there as data URIs, and PenguinMod projects
|
|
16
|
+
* keep plain URLs. Data URIs are written next to the output and referenced
|
|
17
|
+
* from the config file; URLs stay URLs.
|
|
18
|
+
*/
|
|
19
|
+
function restoreExtensions(project, outDir, { fetchRemote = false } = {}) {
|
|
20
|
+
const result = { entries: [], loadEntries: [], files: [], warnings: [] };
|
|
21
|
+
const urls = project && project.extensionURLs;
|
|
22
|
+
if (!urls || typeof urls !== "object") return result;
|
|
23
|
+
|
|
24
|
+
let extensionDir = null;
|
|
25
|
+
let extensionDirProblem = null;
|
|
26
|
+
const resolveExtensionDir = () => {
|
|
27
|
+
if (extensionDir || extensionDirProblem) return;
|
|
28
|
+
const dir = path.join(outDir, "extensions");
|
|
29
|
+
const existing = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
30
|
+
if (existing && !existing.isDirectory()) {
|
|
31
|
+
extensionDirProblem = existing.isSymbolicLink()
|
|
32
|
+
? `"${dir}" is a symlink, so the extension would land outside the output directory`
|
|
33
|
+
: `"${dir}" is not a directory`;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
const expected = path.join(fs.realpathSync(outDir), "extensions");
|
|
38
|
+
const actual = fs.realpathSync(dir);
|
|
39
|
+
if (actual !== expected) {
|
|
40
|
+
extensionDirProblem = `"${dir}" resolves to ${actual}, outside the output directory`;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
extensionDir = dir;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const used = new Set();
|
|
47
|
+
for (const [id, value] of Object.entries(urls)) {
|
|
48
|
+
const uri = typeof value === "string" ? value.trim() : "";
|
|
49
|
+
if (!uri) continue;
|
|
50
|
+
if (/^https?:\/\//i.test(uri)) {
|
|
51
|
+
result.entries.push(uri);
|
|
52
|
+
if (fetchRemote) result.loadEntries.push(uri);
|
|
53
|
+
else
|
|
54
|
+
result.warnings.push(
|
|
55
|
+
`extension ${id} points at a remote URL (${truncate(uri, 48)}); ` +
|
|
56
|
+
"not fetching it, so its blocks stay unsupported — pass " +
|
|
57
|
+
"--fetch-extensions to load it",
|
|
58
|
+
);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!/^data:/i.test(uri)) {
|
|
62
|
+
result.warnings.push(
|
|
63
|
+
`extension ${id} uses an unsupported URL (${truncate(uri, 48)}); ` +
|
|
64
|
+
`add it to ${configFilename()} by hand`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const source = readExtensionDataUri(uri);
|
|
69
|
+
if (source == null) {
|
|
70
|
+
result.warnings.push(
|
|
71
|
+
`extension ${id} has an unreadable data URI; ` +
|
|
72
|
+
`add it to ${configFilename()} by hand`,
|
|
73
|
+
);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const keepEmbedded = (warning) => {
|
|
77
|
+
result.loadEntries.push(uri);
|
|
78
|
+
if (outDir) result.entries.push(uri);
|
|
79
|
+
result.warnings.push(warning);
|
|
80
|
+
};
|
|
81
|
+
if (!outDir) {
|
|
82
|
+
keepEmbedded(
|
|
83
|
+
`extension ${id} is embedded in the archive; pass -o to write it out ` +
|
|
84
|
+
`and reference it from ${configFilename()}`,
|
|
85
|
+
);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
resolveExtensionDir();
|
|
89
|
+
if (extensionDirProblem) {
|
|
90
|
+
keepEmbedded(
|
|
91
|
+
`extension ${id} was not written: ${extensionDirProblem}; its blocks ` +
|
|
92
|
+
"still decompile and the config keeps its source, but the extension " +
|
|
93
|
+
"file is missing from the output",
|
|
94
|
+
);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const base = extensionFileStem(id);
|
|
98
|
+
let stem = base;
|
|
99
|
+
for (let suffix = 2; used.has(stem); suffix += 1)
|
|
100
|
+
stem = `${base}-${suffix}`;
|
|
101
|
+
used.add(stem);
|
|
102
|
+
const file = path.join(extensionDir, `${stem}.js`);
|
|
103
|
+
const relative = `extensions/${stem}.js`;
|
|
104
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
105
|
+
if (existing) {
|
|
106
|
+
if (existing.isFile() && fs.readFileSync(file, "utf8") === source) {
|
|
107
|
+
result.files.push(relative);
|
|
108
|
+
result.entries.push(relative);
|
|
109
|
+
result.loadEntries.push(file);
|
|
110
|
+
} else {
|
|
111
|
+
keepEmbedded(
|
|
112
|
+
`extension ${id} not written to ${relative}: ` +
|
|
113
|
+
(existing.isSymbolicLink()
|
|
114
|
+
? "the path is a symlink"
|
|
115
|
+
: "a different file already exists") +
|
|
116
|
+
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
117
|
+
"write the extension file",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(file, source, { flag: "wx" });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
keepEmbedded(
|
|
126
|
+
`extension ${id} not written to ${relative}: ` +
|
|
127
|
+
(err.code === "EEXIST"
|
|
128
|
+
? "the path appeared while decompiling"
|
|
129
|
+
: `${err.code || err.message}`) +
|
|
130
|
+
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
131
|
+
"write the extension file",
|
|
132
|
+
);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
result.files.push(relative);
|
|
136
|
+
result.entries.push(relative);
|
|
137
|
+
result.loadEntries.push(file);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Sanitize an extension id into a portable file name stem. */
|
|
143
|
+
function extensionFileStem(id) {
|
|
144
|
+
return (
|
|
145
|
+
String(id)
|
|
146
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
147
|
+
.replace(/^[.-]+|[.-]+$/g, "") || "extension"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function writeRestoredConfig(outDir, entries) {
|
|
152
|
+
if (entries.length === 0) return null;
|
|
153
|
+
const existing = configFilenames()
|
|
154
|
+
.map((name) => path.join(outDir, name))
|
|
155
|
+
.find((file) => fs.existsSync(file));
|
|
156
|
+
if (existing) {
|
|
157
|
+
let added = 0;
|
|
158
|
+
for (const entry of entries)
|
|
159
|
+
if (addProjectExtension(existing, entry).added) added += 1;
|
|
160
|
+
return { file: existing, added };
|
|
161
|
+
}
|
|
162
|
+
const file = path.join(outDir, configFilenames()[0]);
|
|
163
|
+
const lines = ["project:", " extensions:"];
|
|
164
|
+
for (const entry of entries) lines.push(` - ${JSON.stringify(entry)}`);
|
|
165
|
+
lines.push("");
|
|
166
|
+
fs.writeFileSync(file, lines.join("\n"));
|
|
167
|
+
return { file, added: entries.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const decompileCommand = {
|
|
171
|
+
name: "decompile",
|
|
172
|
+
aliases: ["d"],
|
|
173
|
+
group: "Core Commands:",
|
|
174
|
+
summary: "Convert a .pmp back into source",
|
|
175
|
+
description: `Convert a .pmp archive back into Triflare Fx source, restoring
|
|
176
|
+
extensions into the project config.`,
|
|
177
|
+
arguments: ["<input.pmp>"],
|
|
178
|
+
options: [
|
|
179
|
+
{ flags: "-o, --output <file>", description: "Output source file" },
|
|
180
|
+
{
|
|
181
|
+
flags: "--assets <dir>",
|
|
182
|
+
description: "Extract project assets into <dir>",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
flags: "--fetch-extensions",
|
|
186
|
+
description: "Load remote extensions while decompiling",
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
examples: [
|
|
190
|
+
"fx decompile build/game.pmp -o src/main.tfx --assets assets",
|
|
191
|
+
"fx decompile build/game.pmp --fetch-extensions",
|
|
192
|
+
],
|
|
193
|
+
async run({ args, options, cwd, reporter }) {
|
|
194
|
+
const input = path.resolve(cwd, args[0]);
|
|
195
|
+
const data = fs.readFileSync(input);
|
|
196
|
+
const files = unzipSync(data);
|
|
197
|
+
const projectJson = files.get("project.json");
|
|
198
|
+
if (!projectJson)
|
|
199
|
+
throw new UsageError("Not a Scratch project: missing project.json");
|
|
200
|
+
const project = JSON.parse(projectJson.toString("utf8"));
|
|
201
|
+
|
|
202
|
+
const output = options.output ? path.resolve(cwd, options.output) : null;
|
|
203
|
+
const outDir = output ? path.dirname(output) : null;
|
|
204
|
+
const restored = restoreExtensions(project, outDir, {
|
|
205
|
+
fetchRemote: Boolean(options.fetchExtensions),
|
|
206
|
+
});
|
|
207
|
+
for (const warning of restored.warnings) reporter.warn(warning);
|
|
208
|
+
|
|
209
|
+
let catalog = {};
|
|
210
|
+
if (restored.loadEntries.length > 0) {
|
|
211
|
+
const loaded = await loadExtensions(restored.loadEntries, {
|
|
212
|
+
baseDir: outDir || cwd,
|
|
213
|
+
});
|
|
214
|
+
catalog = loaded.catalog;
|
|
215
|
+
for (const warning of loaded.warnings) reporter.warn(warning);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const assetDir = options.assets ? path.resolve(cwd, options.assets) : null;
|
|
219
|
+
let assetPrefix = "";
|
|
220
|
+
if (assetDir) {
|
|
221
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
222
|
+
const resolvedAssetDir = fs.realpathSync(assetDir);
|
|
223
|
+
for (const [name, buffer] of files) {
|
|
224
|
+
if (!/^[0-9a-f]{32}\.[a-z0-9]+(?![\s\S])/i.test(name)) continue;
|
|
225
|
+
const destination = path.resolve(resolvedAssetDir, name);
|
|
226
|
+
if (path.dirname(destination) !== resolvedAssetDir) continue;
|
|
227
|
+
if (
|
|
228
|
+
fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()
|
|
229
|
+
)
|
|
230
|
+
continue;
|
|
231
|
+
fs.writeFileSync(destination, buffer);
|
|
232
|
+
}
|
|
233
|
+
const rel = path.relative(outDir || cwd, assetDir).replace(/\\/g, "/");
|
|
234
|
+
assetPrefix = rel ? `${rel}/` : "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const source = decompileProject(project, { assetPrefix, catalog });
|
|
238
|
+
|
|
239
|
+
if (!output) {
|
|
240
|
+
reporter.text(source.endsWith("\n") ? source.slice(0, -1) : source);
|
|
241
|
+
return {
|
|
242
|
+
source,
|
|
243
|
+
output: null,
|
|
244
|
+
extensions: restored.files,
|
|
245
|
+
warnings: restored.warnings,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fs.writeFileSync(output, source);
|
|
250
|
+
const config = writeRestoredConfig(outDir, restored.entries);
|
|
251
|
+
reporter.success(`Decompiled ${args[0]} -> ${options.output}`);
|
|
252
|
+
if (config)
|
|
253
|
+
reporter.success(
|
|
254
|
+
`Restored ${config.added} extension(s) -> ${
|
|
255
|
+
path.relative(cwd, config.file) || config.file
|
|
256
|
+
}`,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
output,
|
|
261
|
+
assets: assetDir ? path.resolve(assetDir) : null,
|
|
262
|
+
extensions: restored.files,
|
|
263
|
+
config: config ? { file: config.file, added: config.added } : null,
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { build } from "../../build.js";
|
|
4
|
+
import { sourceExtension } from "../../brand.js";
|
|
5
|
+
import { packageSignature, resolvePackageConfig } from "../../package.js";
|
|
6
|
+
import { packageProject } from "../../packager.js";
|
|
7
|
+
import { startDevServer } from "../../dev.js";
|
|
8
|
+
import { loadProjectState, resolveInput } from "../context.js";
|
|
9
|
+
import { EXIT, UsageError, errorToJson } from "../errors.js";
|
|
10
|
+
|
|
11
|
+
const EXTENSION = sourceExtension();
|
|
12
|
+
|
|
13
|
+
export const devCommand = {
|
|
14
|
+
name: "dev",
|
|
15
|
+
group: "Core Commands:",
|
|
16
|
+
summary: "Serve a live build and reload on changes",
|
|
17
|
+
description: `Build and serve the project as HTML, rebuilding when sources or
|
|
18
|
+
configuration change.`,
|
|
19
|
+
arguments: [`[input${EXTENSION}|dir]`],
|
|
20
|
+
options: [
|
|
21
|
+
{ flags: "--host [host]", description: "Dev server host" },
|
|
22
|
+
{ flags: "--lan", description: "Listen on all interfaces" },
|
|
23
|
+
{ flags: "--port <port>", description: "Dev server port (default 3000)" },
|
|
24
|
+
{
|
|
25
|
+
flags: "--strict-port",
|
|
26
|
+
description: "Fail instead of choosing the next free port",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
examples: ["fx dev", "fx dev --lan --port 8080", "fx dev --json"],
|
|
30
|
+
jsonEvents: true,
|
|
31
|
+
async run({ args, options, cwd, reporter, prompts, product }) {
|
|
32
|
+
const target = resolveInput(args[0], cwd);
|
|
33
|
+
if (target.file && path.extname(target.file).toLowerCase() === ".pmp")
|
|
34
|
+
throw new UsageError(
|
|
35
|
+
"fx dev needs .tfx sources or a directory, not a .pmp archive",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
let host = "127.0.0.1";
|
|
39
|
+
if (options.lan) host = "0.0.0.0";
|
|
40
|
+
else if (options.host === true) host = "0.0.0.0";
|
|
41
|
+
else if (typeof options.host === "string") host = options.host;
|
|
42
|
+
|
|
43
|
+
if (options.port === true) throw new UsageError("--port needs a number");
|
|
44
|
+
const port = options.port === undefined ? 3000 : Number(options.port);
|
|
45
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
46
|
+
throw new UsageError("--port must be an integer between 0 and 65535");
|
|
47
|
+
|
|
48
|
+
const initialState = await loadProjectState({
|
|
49
|
+
input: args[0],
|
|
50
|
+
cwd,
|
|
51
|
+
options,
|
|
52
|
+
reporter,
|
|
53
|
+
prompts,
|
|
54
|
+
includeExtensions: false,
|
|
55
|
+
});
|
|
56
|
+
const watchDir = initialState.configDir;
|
|
57
|
+
|
|
58
|
+
let lastSb3Hash = null;
|
|
59
|
+
let lastSignature = null;
|
|
60
|
+
let lastHtml = null;
|
|
61
|
+
|
|
62
|
+
const buildForDev = async () => {
|
|
63
|
+
const started = performance.now();
|
|
64
|
+
const state = await loadProjectState({
|
|
65
|
+
input: args[0],
|
|
66
|
+
cwd,
|
|
67
|
+
options,
|
|
68
|
+
reporter,
|
|
69
|
+
prompts,
|
|
70
|
+
includeExtensions: true,
|
|
71
|
+
});
|
|
72
|
+
const result = build({
|
|
73
|
+
...state.target,
|
|
74
|
+
projectConfig: state.loaded.project,
|
|
75
|
+
fxConfig: state.loaded.fx,
|
|
76
|
+
catalog: state.catalog,
|
|
77
|
+
extensionUrls: state.extensionUrls,
|
|
78
|
+
useConfigFile: false,
|
|
79
|
+
});
|
|
80
|
+
for (const warning of result.warnings) reporter.warn(warning);
|
|
81
|
+
|
|
82
|
+
const pkg = resolvePackageConfig(state.loaded.package, {
|
|
83
|
+
baseDir: state.configDir,
|
|
84
|
+
projectName: result.config.name,
|
|
85
|
+
});
|
|
86
|
+
pkg.target = "html";
|
|
87
|
+
|
|
88
|
+
const signature = packageSignature(pkg);
|
|
89
|
+
const sb3Hash = crypto
|
|
90
|
+
.createHash("sha256")
|
|
91
|
+
.update(result.sb3)
|
|
92
|
+
.digest("hex");
|
|
93
|
+
if (
|
|
94
|
+
lastHtml != null &&
|
|
95
|
+
sb3Hash === lastSb3Hash &&
|
|
96
|
+
signature === lastSignature
|
|
97
|
+
)
|
|
98
|
+
return lastHtml;
|
|
99
|
+
|
|
100
|
+
const spinner = prompts.spinner();
|
|
101
|
+
spinner.start("Packaging project...");
|
|
102
|
+
let packaged;
|
|
103
|
+
try {
|
|
104
|
+
packaged = await packageProject({
|
|
105
|
+
sb3: result.sb3,
|
|
106
|
+
projectName: result.config.name,
|
|
107
|
+
packageConfig: pkg,
|
|
108
|
+
onProgress: (message) => spinner.message(String(message)),
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
spinner.stop();
|
|
112
|
+
}
|
|
113
|
+
lastSb3Hash = sb3Hash;
|
|
114
|
+
lastSignature = signature;
|
|
115
|
+
lastHtml = packaged.html ?? packaged.data.toString("utf8");
|
|
116
|
+
reporter.event("build", {
|
|
117
|
+
durationMs: Math.round(performance.now() - started),
|
|
118
|
+
targets: result.project.targets.length,
|
|
119
|
+
});
|
|
120
|
+
return lastHtml;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const server = await startDevServer({
|
|
124
|
+
watchDir,
|
|
125
|
+
build: buildForDev,
|
|
126
|
+
host,
|
|
127
|
+
port,
|
|
128
|
+
strictPort: Boolean(options.strictPort),
|
|
129
|
+
log: {
|
|
130
|
+
error: (message) => {
|
|
131
|
+
reporter.error(message);
|
|
132
|
+
reporter.event("error", {
|
|
133
|
+
error: errorToJson(new Error(String(message))),
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
success: (message) => reporter.success(message),
|
|
137
|
+
warn: (message) => reporter.warn(message),
|
|
138
|
+
info: (message) => reporter.info(message),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const startup = {
|
|
143
|
+
url: server.url,
|
|
144
|
+
localUrls: server.localUrls,
|
|
145
|
+
networkUrls: server.networkUrls,
|
|
146
|
+
host,
|
|
147
|
+
port: server.port ?? port,
|
|
148
|
+
};
|
|
149
|
+
reporter.event("start", startup);
|
|
150
|
+
|
|
151
|
+
if (!reporter.json) {
|
|
152
|
+
reporter.text("");
|
|
153
|
+
reporter.text(
|
|
154
|
+
` ${reporter.brand(product.name)} ${reporter.muted("dev")}`,
|
|
155
|
+
);
|
|
156
|
+
for (const url of server.localUrls)
|
|
157
|
+
reporter.detail("Local", url, { width: 9 });
|
|
158
|
+
for (const url of server.networkUrls)
|
|
159
|
+
reporter.detail("Network", url, { width: 9 });
|
|
160
|
+
reporter.text(` ${reporter.muted("Press Ctrl+C to stop.")}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let stopping = false;
|
|
164
|
+
const shutdown = (signal) => {
|
|
165
|
+
if (stopping) return;
|
|
166
|
+
stopping = true;
|
|
167
|
+
server
|
|
168
|
+
.close()
|
|
169
|
+
.finally(() =>
|
|
170
|
+
process.exit(signal === "SIGINT" ? EXIT.INTERRUPTED : EXIT.OK),
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
174
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
175
|
+
|
|
176
|
+
await server.ready;
|
|
177
|
+
return startup;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
addProjectExtension,
|
|
5
|
+
findConfigPath,
|
|
6
|
+
readProjectExtensions,
|
|
7
|
+
removeProjectExtension,
|
|
8
|
+
} from "../../config.js";
|
|
9
|
+
import { defineCommand } from "../define-command.js";
|
|
10
|
+
import { UsageError } from "../errors.js";
|
|
11
|
+
|
|
12
|
+
function requireConfigPath(cwd, product) {
|
|
13
|
+
const found = findConfigPath(cwd);
|
|
14
|
+
if (!found)
|
|
15
|
+
throw new UsageError("No project config found.", {
|
|
16
|
+
hint: `run "${product.command} init" first`,
|
|
17
|
+
});
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function deprecation(reporter, product) {
|
|
22
|
+
reporter.warn(
|
|
23
|
+
`"${product.command} extension ..." is deprecated and will be removed in a future major version.`,
|
|
24
|
+
);
|
|
25
|
+
reporter.hint(
|
|
26
|
+
`run "${product.command} vendor migrate", then use "${product.command} vendor ..." commands`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const extensionAddCommand = {
|
|
31
|
+
name: "add",
|
|
32
|
+
summary: "Add a legacy project.extensions entry",
|
|
33
|
+
arguments: ["<source>"],
|
|
34
|
+
options: [
|
|
35
|
+
{
|
|
36
|
+
flags: "--unsandboxed <id>",
|
|
37
|
+
description: "Allow an extension id to run unsandboxed",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
run({ args, options, cwd, reporter, product }) {
|
|
41
|
+
deprecation(reporter, product);
|
|
42
|
+
const source = args[0];
|
|
43
|
+
const configPath = requireConfigPath(cwd, product);
|
|
44
|
+
const unsandboxed =
|
|
45
|
+
typeof options.unsandboxed === "string" ? options.unsandboxed : null;
|
|
46
|
+
const result = addProjectExtension(configPath, source, { unsandboxed });
|
|
47
|
+
if (result.added)
|
|
48
|
+
reporter.success(`Added ${source} to ${path.basename(configPath)}`);
|
|
49
|
+
else
|
|
50
|
+
reporter.info(
|
|
51
|
+
`${source} is already listed in ${path.basename(configPath)}`,
|
|
52
|
+
);
|
|
53
|
+
if (unsandboxed && result.unsandboxedAdded)
|
|
54
|
+
reporter.success(`Allowed ${unsandboxed} to run unsandboxed`);
|
|
55
|
+
return { source, config: configPath, ...result };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const extensionRemoveCommand = {
|
|
60
|
+
name: "remove",
|
|
61
|
+
summary: "Remove a legacy project.extensions entry",
|
|
62
|
+
arguments: ["<source|id>"],
|
|
63
|
+
run({ args, cwd, reporter, product }) {
|
|
64
|
+
deprecation(reporter, product);
|
|
65
|
+
const target = args[0];
|
|
66
|
+
const configPath = requireConfigPath(cwd, product);
|
|
67
|
+
const result = removeProjectExtension(configPath, target);
|
|
68
|
+
if (result.extensions.length > 0)
|
|
69
|
+
reporter.success(
|
|
70
|
+
`Removed ${result.extensions.join(", ")} from ${path.basename(configPath)}`,
|
|
71
|
+
);
|
|
72
|
+
if (result.unsandboxedRemoved)
|
|
73
|
+
reporter.success(`Removed ${target} from the unsandboxed list`);
|
|
74
|
+
return { target, config: configPath, ...result };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const extensionListCommand = {
|
|
79
|
+
name: "list",
|
|
80
|
+
summary: "List legacy project.extensions entries",
|
|
81
|
+
run({ cwd, reporter, product }) {
|
|
82
|
+
deprecation(reporter, product);
|
|
83
|
+
const configPath = requireConfigPath(cwd, product);
|
|
84
|
+
const { extensions, unsandboxed } = readProjectExtensions(configPath);
|
|
85
|
+
reporter.heading(
|
|
86
|
+
`${path.basename(configPath)}: ${extensions.length} extension(s)`,
|
|
87
|
+
);
|
|
88
|
+
for (const entry of extensions) reporter.item(entry, "", { width: 0 });
|
|
89
|
+
reporter.detail("Unsandboxed", unsandboxed.join(", ") || "(none)", {
|
|
90
|
+
width: 12,
|
|
91
|
+
});
|
|
92
|
+
return { extensions, unsandboxed };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function registerExtension(program, options) {
|
|
97
|
+
const extension = new Command("extension")
|
|
98
|
+
.description("Deprecated legacy project.extensions commands.")
|
|
99
|
+
.helpGroup("Legacy Commands:");
|
|
100
|
+
extension.copyInheritedSettings(program);
|
|
101
|
+
extension.action(() => extension.outputHelp());
|
|
102
|
+
program.addCommand(extension, { hidden: true });
|
|
103
|
+
for (const spec of [
|
|
104
|
+
extensionAddCommand,
|
|
105
|
+
extensionRemoveCommand,
|
|
106
|
+
extensionListCommand,
|
|
107
|
+
])
|
|
108
|
+
defineCommand(extension, spec, options);
|
|
109
|
+
return extension;
|
|
110
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
disableFeature,
|
|
4
|
+
enableFeature,
|
|
5
|
+
updateFeature,
|
|
6
|
+
} from "../../features.js";
|
|
7
|
+
import { loadVendorConfig, requireConfigPath } from "./vendor/helpers.js";
|
|
8
|
+
import { UsageError } from "../errors.js";
|
|
9
|
+
|
|
10
|
+
const ACTIONS = ["enable", "disable", "update"];
|
|
11
|
+
|
|
12
|
+
export const featureCommand = {
|
|
13
|
+
name: "feature",
|
|
14
|
+
group: "Core Commands:",
|
|
15
|
+
summary: "Install, update or remove a built-in language feature",
|
|
16
|
+
description: `Features are extension-backed language additions. "enable" copies the\nfeature's file into the project and records it under "features:" in fx.yml;\n"update" replaces that copy with the one bundled in this Fx version; and\n"disable" removes both. The file is committed, so Fx upgrades never change it.`,
|
|
17
|
+
arguments: ["<name>", "<action>"],
|
|
18
|
+
options: [
|
|
19
|
+
{
|
|
20
|
+
flags: "--force",
|
|
21
|
+
description: "Replace an installed copy that was edited (enable)",
|
|
22
|
+
},
|
|
23
|
+
{ flags: "--dry-run", description: "Preview update without writing files" },
|
|
24
|
+
],
|
|
25
|
+
examples: [
|
|
26
|
+
"fx feature temporaryvariables enable",
|
|
27
|
+
"fx feature temporaryvariables update --dry-run",
|
|
28
|
+
"fx feature temporaryvariables disable",
|
|
29
|
+
],
|
|
30
|
+
async run({ args, options, cwd, reporter, prompts, yes, product }) {
|
|
31
|
+
const [name, action] = args;
|
|
32
|
+
if (!ACTIONS.includes(action))
|
|
33
|
+
throw new UsageError(
|
|
34
|
+
`Unknown feature action ${JSON.stringify(action)} for ${JSON.stringify(name)}.`,
|
|
35
|
+
{ hint: `use one of: ${ACTIONS.join(", ")}` },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const configPath = requireConfigPath(cwd, product);
|
|
39
|
+
const configDir = path.dirname(configPath);
|
|
40
|
+
if (action === "enable") {
|
|
41
|
+
const loaded = loadVendorConfig(configPath);
|
|
42
|
+
const result = enableFeature({
|
|
43
|
+
configPath,
|
|
44
|
+
configDir,
|
|
45
|
+
name,
|
|
46
|
+
vendorDir: loaded.vendorDir,
|
|
47
|
+
force: Boolean(options.force),
|
|
48
|
+
});
|
|
49
|
+
if (result.changed)
|
|
50
|
+
reporter.success(`${result.feature.title} enabled (${result.file})`);
|
|
51
|
+
else reporter.info(`${result.feature.title} is already installed`);
|
|
52
|
+
if (result.configChanged)
|
|
53
|
+
reporter.info(`Recorded the feature in ${path.basename(configPath)}`);
|
|
54
|
+
return {
|
|
55
|
+
feature: result.name,
|
|
56
|
+
action,
|
|
57
|
+
file: result.file,
|
|
58
|
+
changed: result.changed,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (action === "disable") {
|
|
63
|
+
await prompts.confirmDestructive({
|
|
64
|
+
message: `Disable ${name} and delete its installed file?`,
|
|
65
|
+
yes,
|
|
66
|
+
});
|
|
67
|
+
const result = disableFeature({ configPath, configDir, name });
|
|
68
|
+
if (result.fileRemoved)
|
|
69
|
+
reporter.success(
|
|
70
|
+
`Disabled ${result.feature.title} (${result.file}) and deleted its copy`,
|
|
71
|
+
);
|
|
72
|
+
else
|
|
73
|
+
reporter.success(`Disabled ${result.feature.title} (${result.file})`);
|
|
74
|
+
if (result.filePreserved)
|
|
75
|
+
reporter.info(
|
|
76
|
+
`Kept ${result.file}: it is not the copy Fx installs, so it may be user-owned`,
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
feature: result.name,
|
|
80
|
+
action,
|
|
81
|
+
file: result.file,
|
|
82
|
+
fileRemoved: result.fileRemoved,
|
|
83
|
+
filePreserved: result.filePreserved,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = updateFeature({
|
|
88
|
+
configPath,
|
|
89
|
+
configDir,
|
|
90
|
+
name,
|
|
91
|
+
dryRun: Boolean(options.dryRun),
|
|
92
|
+
});
|
|
93
|
+
if (!result.changed) {
|
|
94
|
+
reporter.info(`${result.feature.title} is already up to date`);
|
|
95
|
+
return {
|
|
96
|
+
feature: result.name,
|
|
97
|
+
action,
|
|
98
|
+
file: result.file,
|
|
99
|
+
changed: false,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
reporter.text(result.diff);
|
|
103
|
+
if (result.dryRun)
|
|
104
|
+
reporter.info(`Dry run: ${result.file} would be replaced`);
|
|
105
|
+
else reporter.success(`Updated ${result.feature.title} (${result.file})`);
|
|
106
|
+
return {
|
|
107
|
+
feature: result.name,
|
|
108
|
+
action,
|
|
109
|
+
file: result.file,
|
|
110
|
+
changed: true,
|
|
111
|
+
dryRun: result.dryRun,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|