@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
package/bin/fx.js
CHANGED
|
@@ -2,1165 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Triflare Fx command line interface.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (alias: d)
|
|
8
|
-
* fx check [input.tfx | dir] (alias: c)
|
|
9
|
-
* fx catalog [dir] [--json] (alias: l)
|
|
10
|
-
* fx init [dir] (alias: i)
|
|
5
|
+
* The implementation lives in `src/cli`; this entrypoint only delegates so it
|
|
6
|
+
* stays usable as a Node API (`run()` returns an exit code) in tests.
|
|
11
7
|
*/
|
|
12
|
-
import
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import crypto from "node:crypto";
|
|
15
|
-
import process from "node:process";
|
|
16
|
-
import { build } from "../src/build.js";
|
|
17
|
-
import { parse } from "../src/parser.js";
|
|
18
|
-
import { discoverSources, mergePrograms } from "../src/project.js";
|
|
19
|
-
import { decompileProject } from "../src/decompiler.js";
|
|
20
|
-
import { unzipSync } from "../src/sb3.js";
|
|
21
|
-
import { CATALOG, blockNames } from "../src/catalog.js";
|
|
22
|
-
import {
|
|
23
|
-
addProjectExtension,
|
|
24
|
-
findConfigPath,
|
|
25
|
-
loadConfigFrom,
|
|
26
|
-
normalizePackageTarget,
|
|
27
|
-
readProjectExtensions,
|
|
28
|
-
removeProjectExtension,
|
|
29
|
-
} from "../src/config.js";
|
|
30
|
-
import { loadExtensions, readExtensionDataUri } from "../src/extensions.js";
|
|
31
|
-
import { readSprite, spriteArchive, spriteSource } from "../src/sprite.js";
|
|
32
|
-
import { packageProject } from "../src/packager.js";
|
|
33
|
-
import {
|
|
34
|
-
extractArchive,
|
|
35
|
-
packageOutputPath,
|
|
36
|
-
packageSignature,
|
|
37
|
-
resolvePackageConfig,
|
|
38
|
-
slugify,
|
|
39
|
-
} from "../src/package.js";
|
|
40
|
-
import { startDevServer } from "../src/dev.js";
|
|
41
|
-
import {
|
|
42
|
-
brandText,
|
|
43
|
-
configFilename,
|
|
44
|
-
configFilenames,
|
|
45
|
-
outputDirectory,
|
|
46
|
-
sourceDirectory,
|
|
47
|
-
sourceExtension,
|
|
48
|
-
} from "../src/brand.js";
|
|
49
|
-
import { createLogger } from "../src/log.js";
|
|
50
|
-
import { loadProduct } from "../src/product.js";
|
|
8
|
+
import { run } from "../src/cli/run.js";
|
|
51
9
|
|
|
52
|
-
|
|
53
|
-
const log = createLogger(product);
|
|
54
|
-
const EXTENSION = sourceExtension(product);
|
|
55
|
-
|
|
56
|
-
function parseArgs(argv) {
|
|
57
|
-
const args = { _: [], flags: {} };
|
|
58
|
-
const booleanFlags = new Set([
|
|
59
|
-
"no-config",
|
|
60
|
-
"json",
|
|
61
|
-
"help",
|
|
62
|
-
"h",
|
|
63
|
-
"version",
|
|
64
|
-
"v",
|
|
65
|
-
"force",
|
|
66
|
-
"extract",
|
|
67
|
-
"lan",
|
|
68
|
-
"strict-port",
|
|
69
|
-
"fetch-extensions",
|
|
70
|
-
]);
|
|
71
|
-
for (let i = 0; i < argv.length; i++) {
|
|
72
|
-
const token = argv[i];
|
|
73
|
-
if (token.startsWith("--")) {
|
|
74
|
-
const [key, inline] = token.slice(2).split("=");
|
|
75
|
-
if (inline !== undefined) args.flags[key] = inline;
|
|
76
|
-
else if (booleanFlags.has(key)) args.flags[key] = true;
|
|
77
|
-
else if (argv[i + 1] && !argv[i + 1].startsWith("-"))
|
|
78
|
-
args.flags[key] = argv[++i];
|
|
79
|
-
else args.flags[key] = true;
|
|
80
|
-
} else if (token.startsWith("-")) {
|
|
81
|
-
const key = token.slice(1);
|
|
82
|
-
if (booleanFlags.has(key)) args.flags[key] = true;
|
|
83
|
-
else if (argv[i + 1] && !argv[i + 1].startsWith("-"))
|
|
84
|
-
args.flags[key] = argv[++i];
|
|
85
|
-
else args.flags[key] = true;
|
|
86
|
-
} else {
|
|
87
|
-
args._.push(token);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return args;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function usage(stream = process.stdout) {
|
|
94
|
-
const command = brandText(product.command, stream);
|
|
95
|
-
const name = brandText(product.name, stream);
|
|
96
|
-
return `${name} ${product.version} — ${product.tagline}
|
|
97
|
-
|
|
98
|
-
Usage:
|
|
99
|
-
${command} build [<input${EXTENSION}>|<dir>] [-o <output.pmp>] [--no-config]
|
|
100
|
-
${command} package [<input${EXTENSION}>|<input.pmp>|<dir>] [-o <output>] [--target <target>] [--extract]
|
|
101
|
-
${command} dev [<input${EXTENSION}>|<dir>] [--host [host]] [--lan] [--port <port>] [--strict-port]
|
|
102
|
-
${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>] [--fetch-extensions]
|
|
103
|
-
${command} check [<input${EXTENSION}>|<dir>]
|
|
104
|
-
${command} catalog [<dir>] [--json]
|
|
105
|
-
${command} init [dir]
|
|
106
|
-
|
|
107
|
-
Commands:
|
|
108
|
-
build (b) Compile a source file, or every ${EXTENSION} file in a directory
|
|
109
|
-
(defaults to the working directory) into one .pmp.
|
|
110
|
-
package (p) Package a source, project archive or directory into HTML or a ZIP
|
|
111
|
-
(Windows/macOS/Linux desktop builds use Electron).
|
|
112
|
-
dev Serve the packaged HTML and reload when the output changes.
|
|
113
|
-
decompile (d) Convert a .pmp back into ${product.name} source, restoring
|
|
114
|
-
extensions into ${configFilename(product)}.
|
|
115
|
-
check (c) Parse a file (or every ${EXTENSION} file in a directory) for errors.
|
|
116
|
-
catalog (l) List ${product.name} blocks, including those from ${configFilename(product)}
|
|
117
|
-
extensions (paths or URLs).
|
|
118
|
-
init (i) Scaffold a new project.
|
|
119
|
-
|
|
120
|
-
Command groups:
|
|
121
|
-
extension add <source> [--unsandboxed <id>]
|
|
122
|
-
Add an extension (path or URL) to ${configFilename(product)}.
|
|
123
|
-
extension remove <source|id>
|
|
124
|
-
Remove an extension from ${configFilename(product)}.
|
|
125
|
-
extension list [--json]
|
|
126
|
-
List configured extensions.
|
|
127
|
-
sprite add <url|file> [--name <name>] [--force]
|
|
128
|
-
Import a .sprite3/.pms sprite as .tfx source.
|
|
129
|
-
sprite build <name> [-o <output.pms>]
|
|
130
|
-
Compile one sprite into a .pms archive.
|
|
131
|
-
sprite remove <name>
|
|
132
|
-
Delete a source file that declares only that sprite.
|
|
133
|
-
sprite list [<dir>] [--json]
|
|
134
|
-
List declared sprites.
|
|
135
|
-
|
|
136
|
-
Options:
|
|
137
|
-
-o, --output Output file path
|
|
138
|
-
--no-config Ignore ${configFilename(product)}
|
|
139
|
-
--assets <dir> Extract project assets into <dir> when decompiling
|
|
140
|
-
--fetch-extensions Load remote (http/https) extensions when decompiling
|
|
141
|
-
--target <t> Packaging target (html, zip, windows, macos, linux, …)
|
|
142
|
-
--extract Unzip a packaged desktop/zip archive
|
|
143
|
-
--host [host] Dev server host; with no value listen on all interfaces
|
|
144
|
-
--lan Alias for listening on all interfaces
|
|
145
|
-
--port <port> Dev server port (default 3000)
|
|
146
|
-
--strict-port Fail instead of picking the next free dev port
|
|
147
|
-
--json Machine readable output (catalog)
|
|
148
|
-
-v, --version Print the version
|
|
149
|
-
`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Long command name -> one-letter alias. */
|
|
153
|
-
const COMMAND_ALIASES = {
|
|
154
|
-
b: "build",
|
|
155
|
-
p: "package",
|
|
156
|
-
d: "decompile",
|
|
157
|
-
c: "check",
|
|
158
|
-
l: "catalog",
|
|
159
|
-
i: "init",
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
function fail(message) {
|
|
163
|
-
log.error(message);
|
|
164
|
-
process.exit(1);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function loadProjectExtensions(dir, config) {
|
|
168
|
-
const catalog = {};
|
|
169
|
-
const extensionUrls = {};
|
|
170
|
-
const unsandboxed = [];
|
|
171
|
-
if (!config.extensions || config.extensions.length === 0)
|
|
172
|
-
return { catalog, extensionUrls, unsandboxed };
|
|
173
|
-
const loaded = await loadExtensions(config.extensions, {
|
|
174
|
-
baseDir: dir,
|
|
175
|
-
expectUnsandboxed: config.unsandboxed,
|
|
176
|
-
});
|
|
177
|
-
Object.assign(catalog, loaded.catalog);
|
|
178
|
-
Object.assign(extensionUrls, loaded.extensionUrls);
|
|
179
|
-
unsandboxed.push(...loaded.unsandboxed);
|
|
180
|
-
for (const warning of loaded.warnings)
|
|
181
|
-
process.stderr.write(`warning: ${warning}\n`);
|
|
182
|
-
return { catalog, extensionUrls, unsandboxed };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Split a positional build/check argument into `{file}` or `{dir}`. */
|
|
186
|
-
function resolveInput(input) {
|
|
187
|
-
if (!input) return { dir: process.cwd() };
|
|
188
|
-
const resolved = path.resolve(input);
|
|
189
|
-
let stat;
|
|
190
|
-
try {
|
|
191
|
-
stat = fs.statSync(resolved);
|
|
192
|
-
} catch {
|
|
193
|
-
fail(`input not found: ${input}`);
|
|
194
|
-
}
|
|
195
|
-
return stat.isDirectory() ? { dir: resolved } : { file: resolved };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function cmdBuild(args) {
|
|
199
|
-
const target = resolveInput(args._[0]);
|
|
200
|
-
const searchDir = target.file ? path.dirname(target.file) : target.dir;
|
|
201
|
-
|
|
202
|
-
const loaded = args.flags["no-config"]
|
|
203
|
-
? { project: null, fx: null, path: null }
|
|
204
|
-
: loadConfigFrom(searchDir);
|
|
205
|
-
const config = loaded.project;
|
|
206
|
-
const extBase = loaded.path ? path.dirname(loaded.path) : searchDir;
|
|
207
|
-
const { catalog, extensionUrls, unsandboxed } = await loadProjectExtensions(
|
|
208
|
-
extBase,
|
|
209
|
-
config || {},
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const result = build({
|
|
213
|
-
...target,
|
|
214
|
-
projectConfig: config,
|
|
215
|
-
fxConfig: loaded.fx,
|
|
216
|
-
catalog,
|
|
217
|
-
extensionUrls,
|
|
218
|
-
useConfigFile: !args.flags["no-config"],
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const explicit = args.flags.o || args.flags.output;
|
|
222
|
-
const output =
|
|
223
|
-
explicit ||
|
|
224
|
-
(target.file
|
|
225
|
-
? target.file.toLowerCase().endsWith(EXTENSION.toLowerCase())
|
|
226
|
-
? `${target.file.slice(0, -EXTENSION.length)}.pmp`
|
|
227
|
-
: `${target.file}.pmp`
|
|
228
|
-
: path.join(
|
|
229
|
-
target.dir,
|
|
230
|
-
outputDirectory(product),
|
|
231
|
-
`${slugify(result.config.name || path.basename(target.dir))}.pmp`,
|
|
232
|
-
));
|
|
233
|
-
|
|
234
|
-
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
235
|
-
fs.writeFileSync(output, result.sb3);
|
|
236
|
-
|
|
237
|
-
const blockCount = result.project.targets.reduce(
|
|
238
|
-
(sum, t) => sum + Object.keys(t.blocks).length,
|
|
239
|
-
0,
|
|
240
|
-
);
|
|
241
|
-
log.success(`Built ${output}`);
|
|
242
|
-
process.stdout.write(
|
|
243
|
-
` sources: ${result.files.length} file(s)\n` +
|
|
244
|
-
` targets: ${result.project.targets.length}\n` +
|
|
245
|
-
` blocks: ${blockCount}\n` +
|
|
246
|
-
` assets: ${result.assets.size}\n` +
|
|
247
|
-
` extensions: ${result.project.extensions.join(", ") || "(none)"}\n` +
|
|
248
|
-
(unsandboxed.length
|
|
249
|
-
? ` unsandboxed: ${unsandboxed.join(", ")} ` +
|
|
250
|
-
"(must be allowed to run unsandboxed in the editor)\n"
|
|
251
|
-
: ""),
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function formatBytes(bytes) {
|
|
256
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
257
|
-
const units = ["KB", "MB", "GB"];
|
|
258
|
-
let value = bytes / 1024;
|
|
259
|
-
let unit = units[0];
|
|
260
|
-
for (let i = 1; value >= 1024 && i < units.length; i += 1) {
|
|
261
|
-
value /= 1024;
|
|
262
|
-
unit = units[i];
|
|
263
|
-
}
|
|
264
|
-
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Read a `.pmp` directly, or build `.tfx`/directory sources into one. */
|
|
268
|
-
async function readPackagingSource(target, loaded) {
|
|
269
|
-
const searchDir = target.file ? path.dirname(target.file) : target.dir;
|
|
270
|
-
if (target.file && path.extname(target.file).toLowerCase() === ".pmp") {
|
|
271
|
-
return {
|
|
272
|
-
sb3: fs.readFileSync(target.file),
|
|
273
|
-
projectName: path.basename(target.file, path.extname(target.file)),
|
|
274
|
-
searchDir,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const extBase = loaded.path ? path.dirname(loaded.path) : searchDir;
|
|
279
|
-
const { catalog, extensionUrls } = await loadProjectExtensions(
|
|
280
|
-
extBase,
|
|
281
|
-
loaded.project || {},
|
|
282
|
-
);
|
|
283
|
-
const result = build({
|
|
284
|
-
...target,
|
|
285
|
-
projectConfig: loaded.project,
|
|
286
|
-
fxConfig: loaded.fx,
|
|
287
|
-
catalog,
|
|
288
|
-
extensionUrls,
|
|
289
|
-
useConfigFile: false,
|
|
290
|
-
});
|
|
291
|
-
return {
|
|
292
|
-
sb3: result.sb3,
|
|
293
|
-
projectName: result.config.name,
|
|
294
|
-
searchDir,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function cmdPackage(args) {
|
|
299
|
-
const target = resolveInput(args._[0]);
|
|
300
|
-
const loaded = args.flags["no-config"]
|
|
301
|
-
? { project: null, fx: null, package: null, path: null }
|
|
302
|
-
: loadConfigFrom(target.file ? path.dirname(target.file) : target.dir);
|
|
303
|
-
const source = await readPackagingSource(target, loaded);
|
|
304
|
-
const baseDir = loaded.path ? path.dirname(loaded.path) : source.searchDir;
|
|
305
|
-
const pkg = resolvePackageConfig(loaded.package, {
|
|
306
|
-
baseDir,
|
|
307
|
-
projectName: source.projectName,
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
if (args.flags.target !== undefined) {
|
|
311
|
-
if (typeof args.flags.target !== "string") fail("--target needs a value");
|
|
312
|
-
pkg.target = normalizePackageTarget(args.flags.target);
|
|
313
|
-
}
|
|
314
|
-
const explicit = args.flags.o || args.flags.output;
|
|
315
|
-
if (explicit !== undefined) {
|
|
316
|
-
if (typeof explicit !== "string") fail("-o/--output needs a value");
|
|
317
|
-
pkg.output = path.resolve(explicit);
|
|
318
|
-
}
|
|
319
|
-
const extract = Boolean(args.flags.extract) || pkg.extract;
|
|
320
|
-
if (extract && pkg.target === "html")
|
|
321
|
-
fail("--extract only applies to zip and desktop targets");
|
|
322
|
-
|
|
323
|
-
const output = packageOutputPath({
|
|
324
|
-
target: pkg.target,
|
|
325
|
-
output: pkg.output,
|
|
326
|
-
name: pkg.name || source.projectName,
|
|
327
|
-
dir: baseDir,
|
|
328
|
-
});
|
|
329
|
-
const result = await packageProject({
|
|
330
|
-
sb3: source.sb3,
|
|
331
|
-
projectName: source.projectName,
|
|
332
|
-
packageConfig: pkg,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
336
|
-
fs.writeFileSync(output, result.data);
|
|
337
|
-
log.success(`Packaged ${output}`);
|
|
338
|
-
process.stdout.write(
|
|
339
|
-
` target: ${result.target}\n` +
|
|
340
|
-
` type: ${result.type}\n` +
|
|
341
|
-
` size: ${formatBytes(result.data.length)}\n`,
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
if (extract) {
|
|
345
|
-
const extension = path.extname(output);
|
|
346
|
-
const extractDir = path.join(
|
|
347
|
-
path.dirname(output),
|
|
348
|
-
path.basename(output, extension),
|
|
349
|
-
);
|
|
350
|
-
const files = await extractArchive(result.data, extractDir);
|
|
351
|
-
log.success(`Extracted ${files.length} file(s) to ${extractDir}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function cmdDev(args) {
|
|
356
|
-
const target = resolveInput(args._[0]);
|
|
357
|
-
if (target.file && path.extname(target.file).toLowerCase() === ".pmp")
|
|
358
|
-
fail("fx dev needs .tfx sources or a directory, not a .pmp archive");
|
|
359
|
-
|
|
360
|
-
const searchDir = target.file ? path.dirname(target.file) : target.dir;
|
|
361
|
-
const useConfigFile = !args.flags["no-config"];
|
|
362
|
-
const configPath = useConfigFile ? findConfigPath(searchDir) : null;
|
|
363
|
-
const baseDir = configPath ? path.dirname(configPath) : searchDir;
|
|
364
|
-
|
|
365
|
-
let host = "127.0.0.1";
|
|
366
|
-
if (args.flags.lan) host = "0.0.0.0";
|
|
367
|
-
else if (args.flags.host === true) host = "0.0.0.0";
|
|
368
|
-
else if (typeof args.flags.host === "string") host = args.flags.host;
|
|
369
|
-
|
|
370
|
-
if (args.flags.port === true) fail("--port needs a number");
|
|
371
|
-
const port = args.flags.port === undefined ? 3000 : Number(args.flags.port);
|
|
372
|
-
|
|
373
|
-
let lastSb3Hash = null;
|
|
374
|
-
let lastSignature = null;
|
|
375
|
-
let lastHtml = null;
|
|
376
|
-
|
|
377
|
-
const buildForDev = async () => {
|
|
378
|
-
const loaded = useConfigFile
|
|
379
|
-
? loadConfigFrom(searchDir)
|
|
380
|
-
: { project: null, fx: null, package: null, path: null };
|
|
381
|
-
const configBase = loaded.path ? path.dirname(loaded.path) : searchDir;
|
|
382
|
-
const { catalog, extensionUrls } = await loadProjectExtensions(
|
|
383
|
-
configBase,
|
|
384
|
-
loaded.project || {},
|
|
385
|
-
);
|
|
386
|
-
const result = build({
|
|
387
|
-
...target,
|
|
388
|
-
projectConfig: loaded.project,
|
|
389
|
-
fxConfig: loaded.fx,
|
|
390
|
-
catalog,
|
|
391
|
-
extensionUrls,
|
|
392
|
-
useConfigFile: false,
|
|
393
|
-
});
|
|
394
|
-
const pkg = resolvePackageConfig(loaded.package, {
|
|
395
|
-
baseDir: configBase,
|
|
396
|
-
projectName: result.config.name,
|
|
397
|
-
});
|
|
398
|
-
// Dev always serves HTML, no matter what the package block requests.
|
|
399
|
-
pkg.target = "html";
|
|
400
|
-
|
|
401
|
-
const signature = packageSignature(pkg);
|
|
402
|
-
const sb3Hash = crypto
|
|
403
|
-
.createHash("sha256")
|
|
404
|
-
.update(result.sb3)
|
|
405
|
-
.digest("hex");
|
|
406
|
-
if (
|
|
407
|
-
lastHtml != null &&
|
|
408
|
-
sb3Hash === lastSb3Hash &&
|
|
409
|
-
signature === lastSignature
|
|
410
|
-
)
|
|
411
|
-
return lastHtml;
|
|
412
|
-
|
|
413
|
-
const packaged = await packageProject({
|
|
414
|
-
sb3: result.sb3,
|
|
415
|
-
projectName: result.config.name,
|
|
416
|
-
packageConfig: pkg,
|
|
417
|
-
});
|
|
418
|
-
lastSb3Hash = sb3Hash;
|
|
419
|
-
lastSignature = signature;
|
|
420
|
-
lastHtml = packaged.html ?? packaged.data.toString("utf8");
|
|
421
|
-
return lastHtml;
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const server = await startDevServer({
|
|
425
|
-
watchDir: baseDir,
|
|
426
|
-
build: buildForDev,
|
|
427
|
-
host,
|
|
428
|
-
port,
|
|
429
|
-
strictPort: Boolean(args.flags["strict-port"]),
|
|
430
|
-
log,
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
process.stdout.write(`\n ${brandText(product.name)} dev\n`);
|
|
434
|
-
for (const url of server.localUrls) log.bullet(`Local: ${url}`);
|
|
435
|
-
for (const url of server.networkUrls) log.bullet(`Network: ${url}`);
|
|
436
|
-
log.info(" Press Ctrl+C to stop.");
|
|
437
|
-
|
|
438
|
-
let stopping = false;
|
|
439
|
-
const shutdown = () => {
|
|
440
|
-
if (stopping) return;
|
|
441
|
-
stopping = true;
|
|
442
|
-
server.close().finally(() => process.exit(0));
|
|
443
|
-
};
|
|
444
|
-
process.once("SIGINT", shutdown);
|
|
445
|
-
process.once("SIGTERM", shutdown);
|
|
446
|
-
|
|
447
|
-
await server.ready;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function cmdCheck(args) {
|
|
451
|
-
const target = resolveInput(args._[0]);
|
|
452
|
-
const files = target.file ? [target.file] : discoverSources(target.dir);
|
|
453
|
-
if (files.length === 0)
|
|
454
|
-
fail(`No ${EXTENSION} files found under ${target.dir}`);
|
|
455
|
-
|
|
456
|
-
const searchDir = target.file ? path.dirname(target.file) : target.dir;
|
|
457
|
-
const loaded = args.flags["no-config"]
|
|
458
|
-
? { fx: null }
|
|
459
|
-
: loadConfigFrom(searchDir);
|
|
460
|
-
const entries = files.map((file) => ({
|
|
461
|
-
ast: parse(fs.readFileSync(file, "utf8"), file),
|
|
462
|
-
file,
|
|
463
|
-
}));
|
|
464
|
-
mergePrograms(entries, {
|
|
465
|
-
partialSprites: loaded.fx?.partialSprites ?? false,
|
|
466
|
-
});
|
|
467
|
-
log.success(`Checked ${files.length} file(s): OK`);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
async function cmdCatalog(args) {
|
|
471
|
-
// Merge the built-in catalog with any extensions declared in fx.yml, so
|
|
472
|
-
// blocks from path- or URL-loaded extensions show up here too.
|
|
473
|
-
const loaded = args.flags["no-config"]
|
|
474
|
-
? { project: null, path: null }
|
|
475
|
-
: loadConfigFrom(args._[0] || process.cwd());
|
|
476
|
-
const config = loaded.project;
|
|
477
|
-
const extBase = loaded.path ? path.dirname(loaded.path) : process.cwd();
|
|
478
|
-
const { catalog } = await loadProjectExtensions(extBase, config || {});
|
|
479
|
-
|
|
480
|
-
const merged = { ...CATALOG, ...catalog };
|
|
481
|
-
const names = Object.keys(merged).sort();
|
|
482
|
-
|
|
483
|
-
if (args.flags.json) {
|
|
484
|
-
process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`);
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const builtIn = blockNames().length;
|
|
488
|
-
process.stdout.write(
|
|
489
|
-
`${product.name} catalog (${names.length} blocks` +
|
|
490
|
-
(names.length > builtIn
|
|
491
|
-
? `, ${names.length - builtIn} from extensions`
|
|
492
|
-
: "") +
|
|
493
|
-
`):\n`,
|
|
494
|
-
);
|
|
495
|
-
for (const name of names) {
|
|
496
|
-
const entry = merged[name];
|
|
497
|
-
const tag = entry.extension ? ` (ext ${entry.extension})` : "";
|
|
498
|
-
process.stdout.write(
|
|
499
|
-
` ${name.padEnd(32)} -> ${entry.opcode} [${entry.kind}]${tag}\n`,
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function truncate(text, max) {
|
|
505
|
-
const value = String(text);
|
|
506
|
-
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Sanitize an extension id into a portable file name stem. */
|
|
510
|
-
function extensionFileStem(id) {
|
|
511
|
-
return (
|
|
512
|
-
String(id)
|
|
513
|
-
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
514
|
-
.replace(/^[.-]+|[.-]+$/g, "") || "extension"
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Recover the extensions a decompiled project references.
|
|
520
|
-
*
|
|
521
|
-
* An archive only remembers extension sources through `project.extensionURLs`:
|
|
522
|
-
* Triflare Fx stores local extension files there as data URIs, and PenguinMod
|
|
523
|
-
* projects keep plain URLs. Data URIs are written next to the output and
|
|
524
|
-
* referenced from the config file; URLs stay URLs.
|
|
525
|
-
*
|
|
526
|
-
* A decompiled archive is untrusted and the output directory is often the
|
|
527
|
-
* user's workspace, so embedded sources are never written over an existing
|
|
528
|
-
* path and never through a symlink. An extension that cannot be written is
|
|
529
|
-
* still loaded from the archive in memory, so its blocks decompile normally,
|
|
530
|
-
* and its own data URI is recorded in the config so later builds keep it.
|
|
531
|
-
*
|
|
532
|
-
* @param {object} project parsed project.json
|
|
533
|
-
* @param {string|null} outDir directory that receives the config and extension files
|
|
534
|
-
* @param {{fetchRemote?: boolean}} [options] `fetchRemote` loads plain URLs
|
|
535
|
-
* instead of skipping them, which costs a network request per extension.
|
|
536
|
-
* @returns {{entries: string[], loadEntries: string[], files: string[], warnings: string[]}}
|
|
537
|
-
*/
|
|
538
|
-
function restoreExtensions(project, outDir, { fetchRemote = false } = {}) {
|
|
539
|
-
const result = { entries: [], loadEntries: [], files: [], warnings: [] };
|
|
540
|
-
const urls = project && project.extensionURLs;
|
|
541
|
-
if (!urls || typeof urls !== "object") return result;
|
|
542
|
-
|
|
543
|
-
// Resolve the directory that receives embedded sources once, refusing a
|
|
544
|
-
// symlinked `extensions/` (which would redirect every write) and verifying
|
|
545
|
-
// that it really is a plain child of the output directory.
|
|
546
|
-
let extensionDir = null;
|
|
547
|
-
let extensionDirProblem = null;
|
|
548
|
-
const resolveExtensionDir = () => {
|
|
549
|
-
if (extensionDir || extensionDirProblem) return;
|
|
550
|
-
const dir = path.join(outDir, "extensions");
|
|
551
|
-
const existing = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
552
|
-
if (existing && !existing.isDirectory()) {
|
|
553
|
-
extensionDirProblem = existing.isSymbolicLink()
|
|
554
|
-
? `"${dir}" is a symlink, so the extension would land outside the output directory`
|
|
555
|
-
: `"${dir}" is not a directory`;
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
559
|
-
const expected = path.join(fs.realpathSync(outDir), "extensions");
|
|
560
|
-
const actual = fs.realpathSync(dir);
|
|
561
|
-
if (actual !== expected) {
|
|
562
|
-
extensionDirProblem = `"${dir}" resolves to ${actual}, outside the output directory`;
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
extensionDir = dir;
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
const used = new Set();
|
|
569
|
-
for (const [id, value] of Object.entries(urls)) {
|
|
570
|
-
const uri = typeof value === "string" ? value.trim() : "";
|
|
571
|
-
if (!uri) continue;
|
|
572
|
-
if (/^https?:\/\//i.test(uri)) {
|
|
573
|
-
result.entries.push(uri);
|
|
574
|
-
if (fetchRemote) result.loadEntries.push(uri);
|
|
575
|
-
else
|
|
576
|
-
result.warnings.push(
|
|
577
|
-
`extension ${id} points at a remote URL (${truncate(uri, 48)}); ` +
|
|
578
|
-
"not fetching it, so its blocks stay unsupported — pass " +
|
|
579
|
-
"--fetch-extensions to load it",
|
|
580
|
-
);
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
if (!/^data:/i.test(uri)) {
|
|
584
|
-
result.warnings.push(
|
|
585
|
-
`extension ${id} uses an unsupported URL (${truncate(uri, 48)}); ` +
|
|
586
|
-
`add it to ${configFilename(product)} by hand`,
|
|
587
|
-
);
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
const source = readExtensionDataUri(uri);
|
|
591
|
-
if (source == null) {
|
|
592
|
-
result.warnings.push(
|
|
593
|
-
`extension ${id} has an unreadable data URI; ` +
|
|
594
|
-
`add it to ${configFilename(product)} by hand`,
|
|
595
|
-
);
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
// When no file can be written, the archive's own copy is the only faithful
|
|
599
|
-
// reference left, so the config keeps its data URI: a later build still has
|
|
600
|
-
// the extension without overwriting, or depending on, a workspace file.
|
|
601
|
-
const keepEmbedded = (warning) => {
|
|
602
|
-
result.loadEntries.push(uri);
|
|
603
|
-
if (outDir) result.entries.push(uri);
|
|
604
|
-
result.warnings.push(warning);
|
|
605
|
-
};
|
|
606
|
-
if (!outDir) {
|
|
607
|
-
keepEmbedded(
|
|
608
|
-
`extension ${id} is embedded in the archive; pass -o to write it out ` +
|
|
609
|
-
`and reference it from ${configFilename(product)}`,
|
|
610
|
-
);
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
resolveExtensionDir();
|
|
614
|
-
if (extensionDirProblem) {
|
|
615
|
-
keepEmbedded(
|
|
616
|
-
`extension ${id} was not written: ${extensionDirProblem}; its blocks ` +
|
|
617
|
-
"still decompile and the config keeps its source, but the extension " +
|
|
618
|
-
"file is missing from the output",
|
|
619
|
-
);
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
const base = extensionFileStem(id);
|
|
623
|
-
let stem = base;
|
|
624
|
-
for (let suffix = 2; used.has(stem); suffix += 1)
|
|
625
|
-
stem = `${base}-${suffix}`;
|
|
626
|
-
used.add(stem);
|
|
627
|
-
const file = path.join(extensionDir, `${stem}.js`);
|
|
628
|
-
const relative = `extensions/${stem}.js`;
|
|
629
|
-
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
630
|
-
if (existing) {
|
|
631
|
-
// Never replace a workspace file. An identical regular file is already
|
|
632
|
-
// the copy we would have written, so reference it and leave it alone.
|
|
633
|
-
if (existing.isFile() && fs.readFileSync(file, "utf8") === source) {
|
|
634
|
-
result.files.push(relative);
|
|
635
|
-
result.entries.push(relative);
|
|
636
|
-
result.loadEntries.push(file);
|
|
637
|
-
} else {
|
|
638
|
-
keepEmbedded(
|
|
639
|
-
`extension ${id} not written to ${relative}: ` +
|
|
640
|
-
(existing.isSymbolicLink()
|
|
641
|
-
? "the path is a symlink"
|
|
642
|
-
: "a different file already exists") +
|
|
643
|
-
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
644
|
-
"write the extension file",
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
try {
|
|
650
|
-
// `wx` fails instead of following a path that appeared in the meantime.
|
|
651
|
-
fs.writeFileSync(file, source, { flag: "wx" });
|
|
652
|
-
} catch (err) {
|
|
653
|
-
keepEmbedded(
|
|
654
|
-
`extension ${id} not written to ${relative}: ` +
|
|
655
|
-
(err.code === "EEXIST"
|
|
656
|
-
? "the path appeared while decompiling"
|
|
657
|
-
: `${err.code || err.message}`) +
|
|
658
|
-
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
659
|
-
"write the extension file",
|
|
660
|
-
);
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
result.files.push(relative);
|
|
664
|
-
result.entries.push(relative);
|
|
665
|
-
result.loadEntries.push(file);
|
|
666
|
-
}
|
|
667
|
-
return result;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Record restored extensions in `fx.yml`, creating one next to the decompiled
|
|
672
|
-
* source when the directory has none yet.
|
|
673
|
-
*
|
|
674
|
-
* @param {string} outDir
|
|
675
|
-
* @param {string[]} entries
|
|
676
|
-
* @returns {{file: string, added: number}|null}
|
|
677
|
-
*/
|
|
678
|
-
function writeRestoredConfig(outDir, entries) {
|
|
679
|
-
if (entries.length === 0) return null;
|
|
680
|
-
const existing = configFilenames()
|
|
681
|
-
.map((name) => path.join(outDir, name))
|
|
682
|
-
.find((file) => fs.existsSync(file));
|
|
683
|
-
if (existing) {
|
|
684
|
-
let added = 0;
|
|
685
|
-
for (const entry of entries)
|
|
686
|
-
if (addProjectExtension(existing, entry).added) added += 1;
|
|
687
|
-
return { file: existing, added };
|
|
688
|
-
}
|
|
689
|
-
const file = path.join(outDir, configFilenames()[0]);
|
|
690
|
-
const lines = ["project:", " extensions:"];
|
|
691
|
-
for (const entry of entries) lines.push(` - ${JSON.stringify(entry)}`);
|
|
692
|
-
lines.push("");
|
|
693
|
-
fs.writeFileSync(file, lines.join("\n"));
|
|
694
|
-
return { file, added: entries.length };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
async function cmdDecompile(args) {
|
|
698
|
-
const input = args._[0];
|
|
699
|
-
if (!input)
|
|
700
|
-
fail(`decompile requires an input .pmp file\n\n${usage(process.stderr)}`);
|
|
701
|
-
const data = fs.readFileSync(input);
|
|
702
|
-
const files = unzipSync(data);
|
|
703
|
-
const projectJson = files.get("project.json");
|
|
704
|
-
if (!projectJson) fail("Not a Scratch project: missing project.json");
|
|
705
|
-
const project = JSON.parse(projectJson.toString("utf8"));
|
|
706
|
-
|
|
707
|
-
const output = args.flags.o || args.flags.output;
|
|
708
|
-
const outDir = output ? path.dirname(path.resolve(output)) : null;
|
|
709
|
-
const restored = restoreExtensions(project, outDir, {
|
|
710
|
-
fetchRemote: Boolean(args.flags["fetch-extensions"]),
|
|
711
|
-
});
|
|
712
|
-
for (const warning of restored.warnings)
|
|
713
|
-
process.stderr.write(`warning: ${warning}\n`);
|
|
714
|
-
|
|
715
|
-
// Extension sources also feed the catalog, so extension blocks decompile as
|
|
716
|
-
// real calls instead of "unsupported block".
|
|
717
|
-
let catalog = {};
|
|
718
|
-
if (restored.loadEntries.length > 0) {
|
|
719
|
-
const loaded = await loadExtensions(restored.loadEntries, {
|
|
720
|
-
baseDir: outDir || process.cwd(),
|
|
721
|
-
});
|
|
722
|
-
catalog = loaded.catalog;
|
|
723
|
-
for (const warning of loaded.warnings)
|
|
724
|
-
process.stderr.write(`warning: ${warning}\n`);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
const assetDir = args.flags.assets;
|
|
728
|
-
let assetPrefix = "";
|
|
729
|
-
if (assetDir) {
|
|
730
|
-
fs.mkdirSync(assetDir, { recursive: true });
|
|
731
|
-
const resolvedAssetDir = fs.realpathSync(assetDir);
|
|
732
|
-
for (const [name, buffer] of files) {
|
|
733
|
-
if (!/^[0-9a-f]{32}\.[a-z0-9]+(?![\s\S])/i.test(name)) continue;
|
|
734
|
-
const destination = path.resolve(resolvedAssetDir, name);
|
|
735
|
-
if (path.dirname(destination) !== resolvedAssetDir) continue;
|
|
736
|
-
// Existing symlinks could redirect an otherwise safe name outside the directory.
|
|
737
|
-
if (
|
|
738
|
-
fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()
|
|
739
|
-
)
|
|
740
|
-
continue;
|
|
741
|
-
fs.writeFileSync(destination, buffer);
|
|
742
|
-
}
|
|
743
|
-
// Reference assets relative to the directory the source file will live in.
|
|
744
|
-
const rel = path
|
|
745
|
-
.relative(outDir || process.cwd(), path.resolve(assetDir))
|
|
746
|
-
.replace(/\\/g, "/");
|
|
747
|
-
assetPrefix = rel ? `${rel}/` : "";
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const source = decompileProject(project, { assetPrefix, catalog });
|
|
751
|
-
if (output) {
|
|
752
|
-
fs.writeFileSync(output, source);
|
|
753
|
-
const config = writeRestoredConfig(outDir, restored.entries);
|
|
754
|
-
log.success(`Decompiled ${input} -> ${output}`);
|
|
755
|
-
if (config)
|
|
756
|
-
log.success(
|
|
757
|
-
`Restored ${config.added} extension(s) -> ${
|
|
758
|
-
path.relative(process.cwd(), config.file) || config.file
|
|
759
|
-
}`,
|
|
760
|
-
);
|
|
761
|
-
} else {
|
|
762
|
-
process.stdout.write(source);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function initFiles() {
|
|
767
|
-
return {
|
|
768
|
-
[configFilename(product)]: `project:
|
|
769
|
-
name: "My ${product.name} Project"
|
|
770
|
-
fps: 60
|
|
771
|
-
width: 480
|
|
772
|
-
height: 360
|
|
773
|
-
turbo: false
|
|
774
|
-
interpolation: true
|
|
775
|
-
fx:
|
|
776
|
-
partialSprites: false
|
|
777
|
-
package:
|
|
778
|
-
target: html
|
|
779
|
-
`,
|
|
780
|
-
[`${sourceDirectory(product)}/main${EXTENSION}`]: `sprite "Cat" {
|
|
781
|
-
costumes: {
|
|
782
|
-
cat: "../assets/cat.svg"
|
|
783
|
-
};
|
|
784
|
-
|
|
785
|
-
let score = 0;
|
|
786
|
-
|
|
787
|
-
when greenFlag {
|
|
788
|
-
motion.goToXY(0, 0);
|
|
789
|
-
control.forever {
|
|
790
|
-
motion.turnRight(1);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
when keyPressed("space") {
|
|
795
|
-
score += 1;
|
|
796
|
-
looks.sayForSecs(operators.join("Score: ", score), 1);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
`,
|
|
800
|
-
"assets/cat.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="101" viewBox="0 0 95 101">
|
|
801
|
-
<ellipse cx="47" cy="60" rx="35" ry="35" fill="#ffab19" stroke="#cf8b17" stroke-width="4"/>
|
|
802
|
-
<circle cx="35" cy="50" r="6" fill="#000"/>
|
|
803
|
-
<circle cx="60" cy="50" r="6" fill="#000"/>
|
|
804
|
-
</svg>
|
|
805
|
-
`,
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function cmdInit(args) {
|
|
810
|
-
const dir = args._[0] || ".";
|
|
811
|
-
for (const [rel, content] of Object.entries(initFiles())) {
|
|
812
|
-
const dest = path.join(dir, rel);
|
|
813
|
-
if (fs.existsSync(dest)) {
|
|
814
|
-
log.warn(`skip ${dest} (exists)`);
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
818
|
-
fs.writeFileSync(dest, content);
|
|
819
|
-
log.bullet(dest);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function requireConfigPath() {
|
|
824
|
-
const found = findConfigPath(process.cwd());
|
|
825
|
-
if (!found)
|
|
826
|
-
fail(
|
|
827
|
-
`No ${configFilename(product)} found. Run "${product.command} init" first.`,
|
|
828
|
-
);
|
|
829
|
-
return found;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
async function readInputBytes(source) {
|
|
833
|
-
if (/^https?:\/\//i.test(source)) {
|
|
834
|
-
const response = await fetch(source, {
|
|
835
|
-
signal: AbortSignal.timeout(15000),
|
|
836
|
-
});
|
|
837
|
-
if (!response.ok)
|
|
838
|
-
throw new Error(
|
|
839
|
-
`Could not download ${source}: ${response.status} ${response.statusText}`,
|
|
840
|
-
);
|
|
841
|
-
return Buffer.from(await response.arrayBuffer());
|
|
842
|
-
}
|
|
843
|
-
return fs.readFileSync(source);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/** Every sprite declaration under `dir`, in source order. */
|
|
847
|
-
function spriteNames(dir) {
|
|
848
|
-
const sprites = [];
|
|
849
|
-
for (const file of discoverSources(dir)) {
|
|
850
|
-
const ast = parse(fs.readFileSync(file, "utf8"), file);
|
|
851
|
-
for (const sprite of ast.sprites) {
|
|
852
|
-
sprites.push({ name: sprite.name, file, partial: sprite.partial });
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
return sprites;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/** Group sprite declarations by name, tracking the base declaration. */
|
|
859
|
-
function spriteGroups(dir) {
|
|
860
|
-
const groups = new Map();
|
|
861
|
-
for (const entry of spriteNames(dir)) {
|
|
862
|
-
let group = groups.get(entry.name);
|
|
863
|
-
if (!group) {
|
|
864
|
-
group = {
|
|
865
|
-
name: entry.name,
|
|
866
|
-
file: entry.file,
|
|
867
|
-
files: [],
|
|
868
|
-
partial: true,
|
|
869
|
-
};
|
|
870
|
-
groups.set(entry.name, group);
|
|
871
|
-
}
|
|
872
|
-
group.files.push(entry.file);
|
|
873
|
-
if (!entry.partial) {
|
|
874
|
-
group.file = entry.file;
|
|
875
|
-
group.partial = false;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
for (const group of groups.values()) {
|
|
879
|
-
// Show the base file first, then fragments in discovery order.
|
|
880
|
-
group.files = [...new Set([group.file, ...group.files])];
|
|
881
|
-
}
|
|
882
|
-
return [...groups.values()];
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function cmdExtensionAdd(args) {
|
|
886
|
-
const source = args._[1];
|
|
887
|
-
if (!source) fail("extension add requires a path or URL");
|
|
888
|
-
const configPath = requireConfigPath();
|
|
889
|
-
let unsandboxed = null;
|
|
890
|
-
if (args.flags.unsandboxed !== undefined) {
|
|
891
|
-
if (typeof args.flags.unsandboxed !== "string")
|
|
892
|
-
fail("--unsandboxed needs an extension id");
|
|
893
|
-
unsandboxed = args.flags.unsandboxed;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
const { added, unsandboxedAdded } = addProjectExtension(configPath, source, {
|
|
897
|
-
unsandboxed,
|
|
898
|
-
});
|
|
899
|
-
if (added) log.success(`Added ${source} to ${path.basename(configPath)}`);
|
|
900
|
-
else log.info(`${source} is already listed in ${path.basename(configPath)}`);
|
|
901
|
-
if (unsandboxed && unsandboxedAdded)
|
|
902
|
-
log.success(`Allowed ${unsandboxed} to run unsandboxed`);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function cmdExtensionRemove(args) {
|
|
906
|
-
const target = args._[1];
|
|
907
|
-
if (!target) fail("extension remove requires a path, URL or extension id");
|
|
908
|
-
const configPath = requireConfigPath();
|
|
909
|
-
const result = removeProjectExtension(configPath, target);
|
|
910
|
-
if (result.extensions.length > 0)
|
|
911
|
-
log.success(
|
|
912
|
-
`Removed ${result.extensions.join(", ")} from ${path.basename(configPath)}`,
|
|
913
|
-
);
|
|
914
|
-
if (result.unsandboxedRemoved)
|
|
915
|
-
log.success(`Removed ${target} from the unsandboxed list`);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
function cmdExtensionList(args) {
|
|
919
|
-
const configPath = requireConfigPath();
|
|
920
|
-
const { extensions, unsandboxed } = readProjectExtensions(configPath);
|
|
921
|
-
if (args.flags.json) {
|
|
922
|
-
process.stdout.write(
|
|
923
|
-
`${JSON.stringify({ extensions, unsandboxed }, null, 2)}\n`,
|
|
924
|
-
);
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
process.stdout.write(
|
|
928
|
-
`${path.basename(configPath)}: ${extensions.length} extension(s)\n`,
|
|
929
|
-
);
|
|
930
|
-
for (const entry of extensions) process.stdout.write(` ${entry}\n`);
|
|
931
|
-
process.stdout.write(`unsandboxed: ${unsandboxed.join(", ") || "(none)"}\n`);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function cmdExtension(args) {
|
|
935
|
-
switch (args._[0]) {
|
|
936
|
-
case "add":
|
|
937
|
-
return cmdExtensionAdd(args);
|
|
938
|
-
case "remove":
|
|
939
|
-
return cmdExtensionRemove(args);
|
|
940
|
-
case "list":
|
|
941
|
-
return cmdExtensionList(args);
|
|
942
|
-
default:
|
|
943
|
-
fail(`extension expects add, remove or list\n\n${usage(process.stderr)}`);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async function cmdSpriteAdd(args) {
|
|
948
|
-
const source = args._[1];
|
|
949
|
-
if (!source) fail("sprite add requires a .sprite3/.pms file or URL");
|
|
950
|
-
const configPath = findConfigPath(process.cwd());
|
|
951
|
-
const rootDir = configPath ? path.dirname(configPath) : process.cwd();
|
|
952
|
-
|
|
953
|
-
const { sprite, assets } = readSprite(await readInputBytes(source));
|
|
954
|
-
const name =
|
|
955
|
-
typeof args.flags.name === "string" ? args.flags.name : sprite.name;
|
|
956
|
-
if (!name) fail("sprite add could not read a sprite name; pass --name");
|
|
957
|
-
sprite.name = name;
|
|
958
|
-
|
|
959
|
-
const existing = spriteNames(rootDir).find((entry) => entry.name === name);
|
|
960
|
-
if (existing)
|
|
961
|
-
fail(
|
|
962
|
-
`A sprite named ${JSON.stringify(name)} already exists in ${existing.file}`,
|
|
963
|
-
);
|
|
964
|
-
|
|
965
|
-
const sourceDir = path.join(rootDir, sourceDirectory(product));
|
|
966
|
-
const file = path.join(sourceDir, `${slugify(name)}${EXTENSION}`);
|
|
967
|
-
if (fs.existsSync(file) && !args.flags.force)
|
|
968
|
-
fail(`${file} already exists; pass --force to overwrite`);
|
|
969
|
-
|
|
970
|
-
const assetsDir = path.join(rootDir, "assets");
|
|
971
|
-
const relative = path.relative(sourceDir, assetsDir).replace(/\\/g, "/");
|
|
972
|
-
const text = spriteSource(sprite, {
|
|
973
|
-
assetPrefix: relative ? `${relative}/` : "",
|
|
974
|
-
});
|
|
975
|
-
parse(text, file);
|
|
976
|
-
|
|
977
|
-
fs.mkdirSync(assetsDir, { recursive: true });
|
|
978
|
-
for (const [assetName, data] of assets)
|
|
979
|
-
fs.writeFileSync(path.join(assetsDir, assetName), data);
|
|
980
|
-
fs.mkdirSync(sourceDir, { recursive: true });
|
|
981
|
-
fs.writeFileSync(file, text);
|
|
982
|
-
log.success(`Added sprite ${name} -> ${path.relative(rootDir, file)}`);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
async function cmdSpriteBuild(args) {
|
|
986
|
-
const name = args._[1];
|
|
987
|
-
if (!name) fail("sprite build requires a sprite name");
|
|
988
|
-
const dir = process.cwd();
|
|
989
|
-
const loaded = args.flags["no-config"]
|
|
990
|
-
? { project: null, fx: null, path: null }
|
|
991
|
-
: loadConfigFrom(dir);
|
|
992
|
-
const config = loaded.project;
|
|
993
|
-
const extBase = loaded.path ? path.dirname(loaded.path) : dir;
|
|
994
|
-
const { catalog, extensionUrls } = await loadProjectExtensions(
|
|
995
|
-
extBase,
|
|
996
|
-
config || {},
|
|
997
|
-
);
|
|
998
|
-
const result = build({
|
|
999
|
-
dir,
|
|
1000
|
-
projectConfig: config,
|
|
1001
|
-
fxConfig: loaded.fx,
|
|
1002
|
-
catalog,
|
|
1003
|
-
extensionUrls,
|
|
1004
|
-
useConfigFile: !args.flags["no-config"],
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
const sprite = result.project.targets.find(
|
|
1008
|
-
(target) => !target.isStage && target.name === name,
|
|
1009
|
-
);
|
|
1010
|
-
if (!sprite) {
|
|
1011
|
-
const available = result.project.targets
|
|
1012
|
-
.filter((target) => !target.isStage)
|
|
1013
|
-
.map((target) => target.name);
|
|
1014
|
-
fail(
|
|
1015
|
-
`No sprite named ${JSON.stringify(name)}. ` +
|
|
1016
|
-
(available.length
|
|
1017
|
-
? `Available: ${available.join(", ")}`
|
|
1018
|
-
: "The project has no sprites."),
|
|
1019
|
-
);
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
const explicit = args.flags.o || args.flags.output;
|
|
1023
|
-
const output =
|
|
1024
|
-
explicit ||
|
|
1025
|
-
path.join(dir, outputDirectory(product), `${slugify(name)}.pms`);
|
|
1026
|
-
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
1027
|
-
fs.writeFileSync(output, spriteArchive(sprite, result.assets));
|
|
1028
|
-
log.success(`Built sprite ${name} -> ${output}`);
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
function cmdSpriteRemove(args) {
|
|
1032
|
-
const name = args._[1];
|
|
1033
|
-
if (!name) fail("sprite remove requires a sprite name");
|
|
1034
|
-
const dir = process.cwd();
|
|
1035
|
-
const matches = spriteNames(dir).filter((entry) => entry.name === name);
|
|
1036
|
-
if (matches.length === 0)
|
|
1037
|
-
fail(`No sprite named ${JSON.stringify(name)} under ${dir}`);
|
|
1038
|
-
|
|
1039
|
-
const files = [...new Set(matches.map((entry) => entry.file))];
|
|
1040
|
-
for (const file of files) {
|
|
1041
|
-
const ast = parse(fs.readFileSync(file, "utf8"), file);
|
|
1042
|
-
const onlyThisSprite =
|
|
1043
|
-
ast.sprites.length > 0 &&
|
|
1044
|
-
ast.sprites.every((sprite) => sprite.name === name) &&
|
|
1045
|
-
ast.topItems.length === 0 &&
|
|
1046
|
-
!ast.stage;
|
|
1047
|
-
if (!onlyThisSprite)
|
|
1048
|
-
fail(`${file} also declares other code; remove the sprite block by hand`);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
for (const file of files) fs.rmSync(file);
|
|
1052
|
-
log.success(
|
|
1053
|
-
`Removed sprite ${name} (${files
|
|
1054
|
-
.map((file) => path.relative(dir, file) || file)
|
|
1055
|
-
.join(", ")})`,
|
|
1056
|
-
);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
function cmdSpriteList(args) {
|
|
1060
|
-
const dir = path.resolve(args._[1] || process.cwd());
|
|
1061
|
-
const sprites = spriteGroups(dir);
|
|
1062
|
-
if (args.flags.json) {
|
|
1063
|
-
process.stdout.write(
|
|
1064
|
-
`${JSON.stringify(
|
|
1065
|
-
sprites.map(({ name, file, files, partial }) => ({
|
|
1066
|
-
name,
|
|
1067
|
-
file: path.relative(dir, file),
|
|
1068
|
-
files: files.map((entry) => path.relative(dir, entry)),
|
|
1069
|
-
partial,
|
|
1070
|
-
})),
|
|
1071
|
-
null,
|
|
1072
|
-
2,
|
|
1073
|
-
)}\n`,
|
|
1074
|
-
);
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
if (sprites.length === 0) {
|
|
1078
|
-
log.info(`No sprites under ${dir}`);
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
for (const { name, files } of sprites)
|
|
1082
|
-
process.stdout.write(
|
|
1083
|
-
` ${name.padEnd(24)} ${files
|
|
1084
|
-
.map((file) => path.relative(dir, file) || file)
|
|
1085
|
-
.join(", ")}\n`,
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
async function cmdSprite(args) {
|
|
1090
|
-
switch (args._[0]) {
|
|
1091
|
-
case "add":
|
|
1092
|
-
return cmdSpriteAdd(args);
|
|
1093
|
-
case "build":
|
|
1094
|
-
return cmdSpriteBuild(args);
|
|
1095
|
-
case "remove":
|
|
1096
|
-
return cmdSpriteRemove(args);
|
|
1097
|
-
case "list":
|
|
1098
|
-
return cmdSpriteList(args);
|
|
1099
|
-
default:
|
|
1100
|
-
fail(
|
|
1101
|
-
`sprite expects add, build, remove or list\n\n${usage(process.stderr)}`,
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function printVersion() {
|
|
1107
|
-
process.stdout.write(`${brandText(product.name)} ${product.version}\n`);
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
async function main() {
|
|
1111
|
-
const argv = process.argv.slice(2);
|
|
1112
|
-
const rawCommand = argv.shift();
|
|
1113
|
-
const command = COMMAND_ALIASES[rawCommand] || rawCommand;
|
|
1114
|
-
const args = parseArgs(argv);
|
|
1115
|
-
|
|
1116
|
-
if (args.flags.version || args.flags.v) {
|
|
1117
|
-
printVersion();
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
switch (command) {
|
|
1122
|
-
case "build":
|
|
1123
|
-
return cmdBuild(args);
|
|
1124
|
-
case "package":
|
|
1125
|
-
return cmdPackage(args);
|
|
1126
|
-
case "dev":
|
|
1127
|
-
return cmdDev(args);
|
|
1128
|
-
case "decompile":
|
|
1129
|
-
return cmdDecompile(args);
|
|
1130
|
-
case "check":
|
|
1131
|
-
return cmdCheck(args);
|
|
1132
|
-
case "catalog":
|
|
1133
|
-
return cmdCatalog(args);
|
|
1134
|
-
case "init":
|
|
1135
|
-
return cmdInit(args);
|
|
1136
|
-
case "extension":
|
|
1137
|
-
return cmdExtension(args);
|
|
1138
|
-
case "sprite":
|
|
1139
|
-
return cmdSprite(args);
|
|
1140
|
-
case "--version":
|
|
1141
|
-
case "-v":
|
|
1142
|
-
printVersion();
|
|
1143
|
-
return;
|
|
1144
|
-
case undefined:
|
|
1145
|
-
case "help":
|
|
1146
|
-
case "--help":
|
|
1147
|
-
case "-h":
|
|
1148
|
-
process.stdout.write(usage());
|
|
1149
|
-
return;
|
|
1150
|
-
default:
|
|
1151
|
-
log.error(
|
|
1152
|
-
`We don't recognize that command: ${JSON.stringify(command)}. ` +
|
|
1153
|
-
`Try '${product.command} help'.`,
|
|
1154
|
-
);
|
|
1155
|
-
process.stdout.write(usage());
|
|
1156
|
-
process.exitCode = 1;
|
|
1157
|
-
return;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
main().catch((err) => {
|
|
1162
|
-
if (err && typeof err.format === "function")
|
|
1163
|
-
process.stderr.write(`${err.format()}\n`);
|
|
1164
|
-
else process.stderr.write(`error: ${err && err.stack ? err.stack : err}\n`);
|
|
1165
|
-
process.exit(1);
|
|
1166
|
-
});
|
|
10
|
+
process.exitCode = await run();
|