@triflare/fx 1.0.2 → 2.0.0
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 +32 -0
- package/bin/fx.js +4 -1160
- package/package.json +9 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/build.js +2 -1
- package/src/catalog.js +117 -140
- 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/index.js +39 -0
- package/src/cli/commands/init.js +169 -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 +257 -0
- package/src/cli/define-command.js +140 -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 +194 -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 +32 -3
- package/src/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/decompiler.js +12 -2
- package/src/extensions.js +311 -48
- package/src/index.js +21 -0
- package/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { build } from "../../build.js";
|
|
4
|
+
import { outputDirectory, sourceExtension } from "../../brand.js";
|
|
5
|
+
import { slugify } from "../../package.js";
|
|
6
|
+
import { loadProjectState } from "../context.js";
|
|
7
|
+
import { formatDuration } from "../format.js";
|
|
8
|
+
|
|
9
|
+
const EXTENSION = sourceExtension();
|
|
10
|
+
|
|
11
|
+
export const buildCommand = {
|
|
12
|
+
name: "build",
|
|
13
|
+
aliases: ["b"],
|
|
14
|
+
group: "Core Commands:",
|
|
15
|
+
summary: "Compile sources into a .pmp",
|
|
16
|
+
description: `Compile a source file, or every ${EXTENSION} file in a directory
|
|
17
|
+
(defaults to the working directory), into one .pmp archive.`,
|
|
18
|
+
arguments: [`[input${EXTENSION}|dir]`],
|
|
19
|
+
options: [{ flags: "-o, --output <file>", description: "Output .pmp path" }],
|
|
20
|
+
examples: [
|
|
21
|
+
"fx build",
|
|
22
|
+
"fx build src/main.tfx -o build/game.pmp",
|
|
23
|
+
"fx build examples/player --no-config",
|
|
24
|
+
],
|
|
25
|
+
async run({ args, options, cwd, reporter, prompts }) {
|
|
26
|
+
const started = performance.now();
|
|
27
|
+
const state = await loadProjectState({
|
|
28
|
+
input: args[0],
|
|
29
|
+
cwd,
|
|
30
|
+
options,
|
|
31
|
+
reporter,
|
|
32
|
+
prompts,
|
|
33
|
+
});
|
|
34
|
+
const { target, loaded, catalog, extensionUrls } = state;
|
|
35
|
+
|
|
36
|
+
const result = build({
|
|
37
|
+
...target,
|
|
38
|
+
projectConfig: loaded.project,
|
|
39
|
+
fxConfig: loaded.fx,
|
|
40
|
+
catalog,
|
|
41
|
+
extensionUrls,
|
|
42
|
+
useConfigFile: false,
|
|
43
|
+
});
|
|
44
|
+
for (const warning of result.warnings) reporter.warn(warning);
|
|
45
|
+
|
|
46
|
+
const explicit = options.output;
|
|
47
|
+
let output;
|
|
48
|
+
if (explicit) {
|
|
49
|
+
output = path.resolve(cwd, explicit);
|
|
50
|
+
} else if (target.file) {
|
|
51
|
+
output = target.file.toLowerCase().endsWith(EXTENSION.toLowerCase())
|
|
52
|
+
? `${target.file.slice(0, -EXTENSION.length)}.pmp`
|
|
53
|
+
: `${target.file}.pmp`;
|
|
54
|
+
} else {
|
|
55
|
+
output = path.join(
|
|
56
|
+
target.dir,
|
|
57
|
+
outputDirectory(),
|
|
58
|
+
`${slugify(result.config.name || path.basename(target.dir))}.pmp`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
62
|
+
fs.writeFileSync(output, result.sb3);
|
|
63
|
+
|
|
64
|
+
const blockCount = result.project.targets.reduce(
|
|
65
|
+
(sum, item) => sum + Object.keys(item.blocks).length,
|
|
66
|
+
0,
|
|
67
|
+
);
|
|
68
|
+
const durationMs = Math.round(performance.now() - started);
|
|
69
|
+
|
|
70
|
+
reporter.success(`Built ${output}`);
|
|
71
|
+
reporter.text("");
|
|
72
|
+
reporter.detail("Sources", `${result.files.length} file(s)`);
|
|
73
|
+
reporter.detail("Targets", result.project.targets.length);
|
|
74
|
+
reporter.detail("Blocks", blockCount);
|
|
75
|
+
reporter.detail("Assets", result.assets.size);
|
|
76
|
+
reporter.detail(
|
|
77
|
+
"Extensions",
|
|
78
|
+
result.project.extensions.join(", ") || "(none)",
|
|
79
|
+
);
|
|
80
|
+
reporter.detail("Time", formatDuration(durationMs));
|
|
81
|
+
if (state.unsandboxed.length)
|
|
82
|
+
reporter.detail(
|
|
83
|
+
"Unsandboxed",
|
|
84
|
+
`${state.unsandboxed.join(", ")} (must be allowed in the editor)`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
output,
|
|
89
|
+
sources: result.files,
|
|
90
|
+
targets: result.project.targets.length,
|
|
91
|
+
blocks: blockCount,
|
|
92
|
+
assets: result.assets.size,
|
|
93
|
+
extensions: result.project.extensions,
|
|
94
|
+
unsandboxed: state.unsandboxed,
|
|
95
|
+
durationMs,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CATALOG, blockNames } from "../../catalog.js";
|
|
2
|
+
import { loadProjectState } from "../context.js";
|
|
3
|
+
|
|
4
|
+
export const catalogCommand = {
|
|
5
|
+
name: "catalog",
|
|
6
|
+
aliases: ["l", "blocks"],
|
|
7
|
+
group: "Core Commands:",
|
|
8
|
+
summary: "List available blocks",
|
|
9
|
+
description: `List block names, including blocks from extensions configured in the
|
|
10
|
+
project.`,
|
|
11
|
+
arguments: ["[dir]"],
|
|
12
|
+
examples: ["fx catalog", "fx catalog --json"],
|
|
13
|
+
async run({ args, cwd, options, reporter, product, prompts }) {
|
|
14
|
+
const state = await loadProjectState({
|
|
15
|
+
input: args[0],
|
|
16
|
+
cwd,
|
|
17
|
+
options,
|
|
18
|
+
reporter,
|
|
19
|
+
prompts,
|
|
20
|
+
});
|
|
21
|
+
const merged = { ...CATALOG, ...state.catalog };
|
|
22
|
+
const names = Object.keys(merged).sort();
|
|
23
|
+
const builtIn = blockNames().length;
|
|
24
|
+
|
|
25
|
+
reporter.heading(
|
|
26
|
+
`${product.name} catalog (${names.length} blocks` +
|
|
27
|
+
(names.length > builtIn
|
|
28
|
+
? `, ${names.length - builtIn} from extensions`
|
|
29
|
+
: "") +
|
|
30
|
+
"):",
|
|
31
|
+
);
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
const entry = merged[name];
|
|
34
|
+
reporter.item(name, `-> ${entry.opcode} [${entry.kind}]`, {
|
|
35
|
+
width: 32,
|
|
36
|
+
suffix: entry.extension ? `(ext ${entry.extension})` : "",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return merged;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parse } from "../../parser.js";
|
|
3
|
+
import { discoverSources, mergePrograms } from "../../project.js";
|
|
4
|
+
import { sourceExtension } from "../../brand.js";
|
|
5
|
+
import { loadProjectState } from "../context.js";
|
|
6
|
+
import { UsageError } from "../errors.js";
|
|
7
|
+
|
|
8
|
+
const EXTENSION = sourceExtension();
|
|
9
|
+
|
|
10
|
+
export const checkCommand = {
|
|
11
|
+
name: "check",
|
|
12
|
+
aliases: ["c"],
|
|
13
|
+
group: "Core Commands:",
|
|
14
|
+
summary: "Parse sources and report errors",
|
|
15
|
+
description: `Parse a file, or every ${EXTENSION} file in a directory, without
|
|
16
|
+
building an archive.`,
|
|
17
|
+
arguments: [`[input${EXTENSION}|dir]`],
|
|
18
|
+
examples: ["fx check", "fx check src/main.tfx"],
|
|
19
|
+
async run({ args, cwd, options, reporter }) {
|
|
20
|
+
const state = await loadProjectState({
|
|
21
|
+
input: args[0],
|
|
22
|
+
cwd,
|
|
23
|
+
options,
|
|
24
|
+
reporter,
|
|
25
|
+
includeExtensions: false,
|
|
26
|
+
migrateLegacy: false,
|
|
27
|
+
});
|
|
28
|
+
const { target, loaded } = state;
|
|
29
|
+
const files = target.file ? [target.file] : discoverSources(target.dir);
|
|
30
|
+
if (files.length === 0)
|
|
31
|
+
throw new UsageError(`No ${EXTENSION} files found under ${target.dir}`);
|
|
32
|
+
|
|
33
|
+
const entries = files.map((file) => ({
|
|
34
|
+
ast: parse(fs.readFileSync(file, "utf8"), file),
|
|
35
|
+
file,
|
|
36
|
+
}));
|
|
37
|
+
mergePrograms(entries, {
|
|
38
|
+
partialSprites: loaded.fx?.partialSprites ?? false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
reporter.success(`Checked ${files.length} file(s): OK`);
|
|
42
|
+
return { files, ok: true };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { completionCandidates } from "../completion/index.js";
|
|
2
|
+
import { bashCompletion } from "../completion/bash.js";
|
|
3
|
+
import { fishCompletion } from "../completion/fish.js";
|
|
4
|
+
import { zshCompletion } from "../completion/zsh.js";
|
|
5
|
+
import { UsageError } from "../errors.js";
|
|
6
|
+
|
|
7
|
+
const SHELLS = new Set(["bash", "zsh", "fish"]);
|
|
8
|
+
|
|
9
|
+
export const completionCommand = {
|
|
10
|
+
name: "completion",
|
|
11
|
+
group: "Utility Commands:",
|
|
12
|
+
summary: "Print shell completion setup",
|
|
13
|
+
description: `Print a completion script for bash, zsh, or fish. Add the script
|
|
14
|
+
to your shell profile, or source it directly.`,
|
|
15
|
+
arguments: ["<shell>"],
|
|
16
|
+
examples: [
|
|
17
|
+
"fx completion bash >> ~/.bashrc",
|
|
18
|
+
"fx completion zsh > ~/.zfunc/_fx",
|
|
19
|
+
"fx completion fish > ~/.config/fish/completions/fx.fish",
|
|
20
|
+
],
|
|
21
|
+
run({ args, reporter, product }) {
|
|
22
|
+
const shell = args[0];
|
|
23
|
+
if (!SHELLS.has(shell))
|
|
24
|
+
throw new UsageError(
|
|
25
|
+
`completion requires one of: ${[...SHELLS].join(", ")}`,
|
|
26
|
+
);
|
|
27
|
+
const command = product.command;
|
|
28
|
+
const script =
|
|
29
|
+
shell === "bash"
|
|
30
|
+
? bashCompletion(command)
|
|
31
|
+
: shell === "zsh"
|
|
32
|
+
? zshCompletion(command)
|
|
33
|
+
: fishCompletion(command);
|
|
34
|
+
reporter.text(script.endsWith("\n") ? script.slice(0, -1) : script);
|
|
35
|
+
return { shell, script };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const completeCommand = {
|
|
40
|
+
name: "__complete",
|
|
41
|
+
hidden: true,
|
|
42
|
+
summary: "Internal shell completion protocol",
|
|
43
|
+
arguments: ["[words...]"],
|
|
44
|
+
run({ args, command, reporter }) {
|
|
45
|
+
const words = args[0] == null ? [] : args[0];
|
|
46
|
+
const list = Array.isArray(words) ? words : [words];
|
|
47
|
+
const program = command.parent ?? command;
|
|
48
|
+
// The generated shell scripts pass their own name first as a marker.
|
|
49
|
+
const input = SHELLS.has(list[0]) ? list.slice(1) : list;
|
|
50
|
+
const candidates = completionCandidates(program, input);
|
|
51
|
+
if (candidates.length > 0) reporter.text(candidates.join("\n"));
|
|
52
|
+
return { candidates };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -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
|
+
};
|