@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.
Files changed (54) hide show
  1. package/README.md +32 -0
  2. package/bin/fx.js +4 -1160
  3. package/package.json +9 -3
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/build.js +2 -1
  7. package/src/catalog.js +117 -140
  8. package/src/cli/commands/build.js +98 -0
  9. package/src/cli/commands/catalog.js +42 -0
  10. package/src/cli/commands/check.js +44 -0
  11. package/src/cli/commands/completion.js +54 -0
  12. package/src/cli/commands/decompile.js +266 -0
  13. package/src/cli/commands/dev.js +179 -0
  14. package/src/cli/commands/extension.js +110 -0
  15. package/src/cli/commands/index.js +39 -0
  16. package/src/cli/commands/init.js +169 -0
  17. package/src/cli/commands/package.js +137 -0
  18. package/src/cli/commands/sprite/add.js +85 -0
  19. package/src/cli/commands/sprite/build.js +62 -0
  20. package/src/cli/commands/sprite/helpers.js +66 -0
  21. package/src/cli/commands/sprite/index.js +29 -0
  22. package/src/cli/commands/sprite/list.js +31 -0
  23. package/src/cli/commands/sprite/remove.js +51 -0
  24. package/src/cli/commands/vendor/add.js +98 -0
  25. package/src/cli/commands/vendor/helpers.js +50 -0
  26. package/src/cli/commands/vendor/index.js +39 -0
  27. package/src/cli/commands/vendor/install.js +64 -0
  28. package/src/cli/commands/vendor/list.js +45 -0
  29. package/src/cli/commands/vendor/migrate.js +47 -0
  30. package/src/cli/commands/vendor/outdated.js +51 -0
  31. package/src/cli/commands/vendor/remove.js +34 -0
  32. package/src/cli/commands/vendor/update.js +49 -0
  33. package/src/cli/commands/vendor/verify.js +44 -0
  34. package/src/cli/completion/bash.js +10 -0
  35. package/src/cli/completion/fish.js +9 -0
  36. package/src/cli/completion/index.js +97 -0
  37. package/src/cli/completion/zsh.js +11 -0
  38. package/src/cli/context.js +257 -0
  39. package/src/cli/define-command.js +140 -0
  40. package/src/cli/errors.js +111 -0
  41. package/src/cli/format.js +31 -0
  42. package/src/cli/prompts.js +183 -0
  43. package/src/cli/reporter.js +194 -0
  44. package/src/cli/run.js +248 -0
  45. package/src/cli/templates/index.js +112 -0
  46. package/src/cli/theme.js +84 -0
  47. package/src/compiler.js +32 -3
  48. package/src/config-edit.js +280 -1
  49. package/src/config.js +137 -3
  50. package/src/decompiler.js +12 -2
  51. package/src/extensions.js +311 -48
  52. package/src/index.js +21 -0
  53. package/src/vendor.js +1323 -0
  54. 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
- * fx build [input.tfx | dir] [-o out.pmp] [--no-config] (alias: b)
6
- * fx decompile <input.pmp> [-o out.tfx] [--assets <dir>] [--fetch-extensions]
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 fs from "node:fs";
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
- const product = loadProduct();
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();