@openfairygui/cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.mjs +328 -54
  2. package/package.json +7 -4
package/dist/cli.mjs CHANGED
@@ -745,10 +745,10 @@ var Property = class extends GraphNode {
745
745
  return this.set("name", name);
746
746
  }
747
747
  getExtras() {
748
- return this.get("extras");
748
+ return structuredClone(this.get("extras"));
749
749
  }
750
750
  setExtras(extras) {
751
- return this.set("extras", extras);
751
+ return this.set("extras", structuredClone(extras));
752
752
  }
753
753
  clone() {
754
754
  const PropertyClass = this.constructor;
@@ -829,7 +829,7 @@ var ExtensibleProperty = class extends Property {
829
829
  };
830
830
  //#endregion
831
831
  //#region ../core/src/constants.ts
832
- const VERSION = `v0.3.0`;
832
+ const VERSION = `v0.3.1`;
833
833
  /** Binary package file magic number: "FGUI" as uint32. */
834
834
  const FGUI_MAGIC = 1179080009;
835
835
  /** Null string index in the binary string table. */
@@ -1219,10 +1219,10 @@ var Root = class extends ExtensibleProperty {
1219
1219
  return this.setBranches([...this.listBranches(), branch]);
1220
1220
  }
1221
1221
  getSettings() {
1222
- return this.get("settings");
1222
+ return structuredClone(this.get("settings"));
1223
1223
  }
1224
1224
  setSettings(settings) {
1225
- return this.set("settings", settings);
1225
+ return this.set("settings", structuredClone(settings));
1226
1226
  }
1227
1227
  /****** Extensions ******/
1228
1228
  listExtensionsUsed() {
@@ -2263,9 +2263,24 @@ var DragonBonesResource = class extends SkeletonResourceBase {
2263
2263
  * @category Properties
2264
2264
  */
2265
2265
  var Component = class extends ExtensibleProperty {
2266
+ _binaryDirty = true;
2266
2267
  init() {
2267
2268
  this.propertyType = PropertyType.COMPONENT;
2268
2269
  }
2270
+ /** @internal */
2271
+ _markBinaryClean() {
2272
+ this._binaryDirty = false;
2273
+ return this;
2274
+ }
2275
+ /** @internal */
2276
+ _markBinaryDirty() {
2277
+ this._binaryDirty = true;
2278
+ return this;
2279
+ }
2280
+ /** @internal */
2281
+ _isBinaryDirty() {
2282
+ return this._binaryDirty;
2283
+ }
2269
2284
  getDefaults() {
2270
2285
  return Object.assign(super.getDefaults(), {
2271
2286
  id: "",
@@ -15784,7 +15799,6 @@ const { Deflate, deflate, deflateRaw, gzip } = deflate_1$1;
15784
15799
  const { Inflate, inflate, inflateRaw, ungzip } = inflate_1$1;
15785
15800
  var deflateRaw_1 = deflateRaw;
15786
15801
  var Inflate_1 = Inflate;
15787
- var inflateRaw_1 = inflateRaw;
15788
15802
  //#endregion
15789
15803
  //#region ../core/src/utils/image-info.ts
15790
15804
  var import_jpeg_js = require_jpeg_js();
@@ -16180,6 +16194,99 @@ function probeRasterImageDimensions(data) {
16180
16194
  return readPngInfo(data, false) ?? readJpegInfo(data, false);
16181
16195
  }
16182
16196
  //#endregion
16197
+ //#region ../core/src/utils/svg-validation.ts
16198
+ const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
16199
+ const MAX_SVG_NODES = 5e4;
16200
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
16201
+ const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
16202
+ const UNSAFE_SVG_ELEMENTS = new Set([
16203
+ "a",
16204
+ "animate",
16205
+ "animatecolor",
16206
+ "animatemotion",
16207
+ "animatetransform",
16208
+ "audio",
16209
+ "canvas",
16210
+ "discard",
16211
+ "embed",
16212
+ "feimage",
16213
+ "foreignobject",
16214
+ "iframe",
16215
+ "image",
16216
+ "object",
16217
+ "script",
16218
+ "set",
16219
+ "style",
16220
+ "video"
16221
+ ]);
16222
+ function invalidSvg(message) {
16223
+ throw new Error(`Invalid or unsafe SVG source (${message}).`);
16224
+ }
16225
+ function validateAttribute(name, value) {
16226
+ const normalizedName = name.toLowerCase();
16227
+ if (normalizedName === "xmlns") {
16228
+ if (value !== SVG_NAMESPACE) invalidSvg("the default namespace must be SVG");
16229
+ return;
16230
+ }
16231
+ if (normalizedName === "xmlns:xlink") {
16232
+ if (value !== XLINK_NAMESPACE) invalidSvg("the xlink namespace is invalid");
16233
+ return;
16234
+ }
16235
+ if (normalizedName.includes(":") && normalizedName !== "xlink:href" && normalizedName !== "xml:space") invalidSvg(`qualified attribute "${name}" is not allowed`);
16236
+ const localName = normalizedName.split(":").at(-1);
16237
+ const text = String(value);
16238
+ if (localName.startsWith("on")) invalidSvg(`event attribute "${name}" is not allowed`);
16239
+ if (localName === "style" || localName === "src") invalidSvg(`attribute "${name}" is not allowed`);
16240
+ if (localName === "href" && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) invalidSvg(`external reference in "${name}" is not allowed`);
16241
+ if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) invalidSvg(`external URL in "${name}" is not allowed`);
16242
+ for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
16243
+ const reference = (match[1] ?? "").trim().replace(/^(['"])(.*)\1$/u, "$2");
16244
+ if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) invalidSvg(`external url() in "${name}" is not allowed`);
16245
+ }
16246
+ }
16247
+ /** Validate SVG bytes before handing them to a host image decoder. */
16248
+ function validateSafeSvgSource(bytes) {
16249
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) invalidSvg("source size is unsupported");
16250
+ let source;
16251
+ try {
16252
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
16253
+ } catch {
16254
+ invalidSvg("source is not valid UTF-8");
16255
+ }
16256
+ if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) invalidSvg("DTD, entities, and stylesheets are not allowed");
16257
+ if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) invalidSvg("source is not well-formed XML");
16258
+ const roots = new XMLParser({
16259
+ preserveOrder: true,
16260
+ ignoreAttributes: false,
16261
+ attributeNamePrefix: "",
16262
+ parseAttributeValue: false,
16263
+ parseTagValue: false,
16264
+ processEntities: false,
16265
+ trimValues: false
16266
+ }).parse(source).flatMap((entry) => Object.keys(entry).filter((name) => name !== ":@" && !name.startsWith("#") && !name.startsWith("?")).map((name) => ({
16267
+ entry,
16268
+ name
16269
+ })));
16270
+ if (roots.length !== 1 || roots[0].name !== "svg") invalidSvg("a single unqualified <svg> root is required");
16271
+ if (roots[0].entry[":@"]?.xmlns !== SVG_NAMESPACE) invalidSvg("the SVG namespace is required");
16272
+ const pending = [roots[0].entry];
16273
+ let nodeCount = 0;
16274
+ while (pending.length > 0) {
16275
+ const current = pending.pop();
16276
+ for (const [name, value] of Object.entries(current)) {
16277
+ if (name === ":@" || name.startsWith("#") || name.startsWith("?")) continue;
16278
+ if (++nodeCount > MAX_SVG_NODES) invalidSvg("node count exceeds the supported limit");
16279
+ if (name.includes(":")) invalidSvg(`qualified element <${name}> is not allowed`);
16280
+ if (UNSAFE_SVG_ELEMENTS.has(name.toLowerCase())) invalidSvg(`element <${name}> is not allowed`);
16281
+ for (const [attributeName, attributeValue] of Object.entries(current[":@"] ?? {})) validateAttribute(attributeName, attributeValue);
16282
+ if (Array.isArray(value)) {
16283
+ for (const child of value) if (child && typeof child === "object" && !Array.isArray(child)) pending.push(child);
16284
+ }
16285
+ }
16286
+ }
16287
+ return source;
16288
+ }
16289
+ //#endregion
16183
16290
  //#region ../core/src/document.ts
16184
16291
  /**
16185
16292
  * Wraps a FairyGUI project and its resources for easier modification.
@@ -16206,12 +16313,32 @@ var Document = class Document {
16206
16313
  _root = new Root(this._graph);
16207
16314
  _logger = Logger.DEFAULT_INSTANCE;
16208
16315
  _projectDir = "";
16316
+ _hasBinaryComponents = false;
16209
16317
  static _GRAPH_DOCUMENTS = /* @__PURE__ */ new WeakMap();
16210
16318
  static fromGraph(graph) {
16211
16319
  return Document._GRAPH_DOCUMENTS.get(graph) || null;
16212
16320
  }
16213
16321
  constructor() {
16214
16322
  Document._GRAPH_DOCUMENTS.set(this._graph, this);
16323
+ this._graph.addEventListener("node:change", (event) => {
16324
+ if (!this._hasBinaryComponents) return;
16325
+ const pending = [event.target];
16326
+ const visited = /* @__PURE__ */ new Set();
16327
+ while (pending.length > 0) {
16328
+ const current = pending.pop();
16329
+ if (visited.has(current)) continue;
16330
+ visited.add(current);
16331
+ if (current instanceof Component) {
16332
+ current._markBinaryDirty();
16333
+ continue;
16334
+ }
16335
+ pending.push(...current.listParents());
16336
+ }
16337
+ });
16338
+ }
16339
+ /** @internal */
16340
+ _trackBinaryComponent() {
16341
+ this._hasBinaryComponents = true;
16215
16342
  }
16216
16343
  getRoot() {
16217
16344
  return this._root;
@@ -17212,16 +17339,8 @@ function sourceExtension(resource) {
17212
17339
  }
17213
17340
  function validSvg(bytes) {
17214
17341
  try {
17215
- if (bytes.byteLength === 0 || bytes.byteLength > 8 * 1024 * 1024) return false;
17216
- const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17217
- if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) return false;
17218
- if (!/<svg(?:\s|>)/i.test(source)) return false;
17219
- if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) return false;
17220
- if (/<(?:a|animate|animatecolor|animatemotion|animatetransform|audio|canvas|discard|embed|feimage|foreignobject|iframe|image|object|script|set|style|video)(?:\s|>)/iu.test(source)) return false;
17221
- if (/\s(?:on[\w:-]*|style|src)\s*=/iu.test(source)) return false;
17222
- if (/\s(?:href|xlink:href)\s*=\s*['"](?!#[A-Za-z_][\w:.-]*['"])/iu.test(source)) return false;
17223
- const urlSource = source.replace(/\sxmlns\s*=\s*(['"])http:\/\/www\.w3\.org\/2000\/svg\1/giu, "");
17224
- return !/(?:https?:|file:|javascript:|data:|\/\/)|url\s*\(\s*(?!['"]?#[A-Za-z_])/iu.test(urlSource);
17342
+ validateSafeSvgSource(bytes);
17343
+ return true;
17225
17344
  } catch {
17226
17345
  return false;
17227
17346
  }
@@ -23309,7 +23428,11 @@ var ProjectWriter = class {
23309
23428
  if (settings[key] === void 0 && await fs.exists(filePath)) staleOptionalSettings.push(filePath);
23310
23429
  }
23311
23430
  if (staleOptionalSettings.length > 0 && !fs.unlink) throw new Error("Project settings cleanup requires a FileSystem.unlink() implementation.");
23312
- const fairyXml = `<?xml version="1.0" encoding="utf-8"?>\n<projectDescription id="${root.getProjectId()}" type="${this._projectTypeName(root.getProjectType())}" version="${root.getVersion() || "3.0"}"/>\n`;
23431
+ const fairyXml = `<?xml version="1.0" encoding="utf-8"?>\n<projectDescription${renderXmlAttrs({
23432
+ id: root.getProjectId(),
23433
+ type: this._projectTypeName(root.getProjectType()),
23434
+ version: root.getVersion() || "3.0"
23435
+ })}/>\n`;
23313
23436
  await fs.writeFile(projectPath, fairyXml);
23314
23437
  await fs.mkdir(settingsPath);
23315
23438
  for (const [fileName, key] of Object.entries({
@@ -24858,6 +24981,41 @@ function decodeComponentDefinition(resource, rawData, extensionTypeCode, doc) {
24858
24981
  }
24859
24982
  //#endregion
24860
24983
  //#region ../core/src/io/binary-reader.ts
24984
+ const DEFAULT_BINARY_READ_LIMITS = {
24985
+ maxCompressedBytes: 64 * 1024 * 1024,
24986
+ maxDecompressedBytes: 256 * 1024 * 1024,
24987
+ maxCompressionRatio: 200
24988
+ };
24989
+ function readLimits(options) {
24990
+ const limits = {
24991
+ ...DEFAULT_BINARY_READ_LIMITS,
24992
+ ...options.limits
24993
+ };
24994
+ for (const [name, value] of Object.entries(limits)) if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number.`);
24995
+ return limits;
24996
+ }
24997
+ function inflateRawWithLimits(input, limits) {
24998
+ if (input.byteLength > limits.maxCompressedBytes) throw new Error(`FairyGUI binary compressed data exceeds ${limits.maxCompressedBytes} bytes.`);
24999
+ const maxOutputBytes = Math.min(limits.maxDecompressedBytes, Math.floor(input.byteLength * limits.maxCompressionRatio));
25000
+ const chunks = [];
25001
+ let outputLength = 0;
25002
+ const inflater = new Inflate_1({ raw: true });
25003
+ inflater.onData = (chunk) => {
25004
+ if (!(chunk instanceof Uint8Array)) throw new Error("FairyGUI binary inflate returned non-binary data.");
25005
+ outputLength += chunk.byteLength;
25006
+ if (outputLength > maxOutputBytes) throw new Error(`FairyGUI binary decompressed data exceeds the configured ${maxOutputBytes} byte budget.`);
25007
+ chunks.push(chunk);
25008
+ };
25009
+ inflater.push(input, true);
25010
+ if (inflater.err !== 0) throw new Error(`Invalid compressed FairyGUI binary data: ${inflater.msg}`);
25011
+ const output = new Uint8Array(outputLength);
25012
+ let offset = 0;
25013
+ for (const chunk of chunks) {
25014
+ output.set(chunk, offset);
25015
+ offset += chunk.byteLength;
25016
+ }
25017
+ return output;
25018
+ }
24861
25019
  /**
24862
25020
  * Binary item type codes as used in the .fui format.
24863
25021
  * @internal
@@ -24958,7 +25116,7 @@ function decodeFontGlyphs(doc, resource, buf) {
24958
25116
  for (let index = 0; index < glyphCount; index += 1) {
24959
25117
  const chunkSize = buf.getInt16();
24960
25118
  const nextPos = buf.pos + chunkSize;
24961
- const charId = buf.getInt16();
25119
+ const charId = buf.getUint16();
24962
25120
  const glyph = doc.createFontGlyph(`${resource.getId()}_${charId || index}`);
24963
25121
  glyph.setCharId(charId).setChar(decodeChar(charId)).setImg(buf.readS() ?? "").setX(buf.getInt32()).setY(buf.getInt32()).setXOffset(buf.getInt32()).setYOffset(buf.getInt32()).setWidth(buf.getInt32()).setHeight(buf.getInt32()).setAdvance(buf.getInt32()).setChannel(buf.getUint8());
24964
25122
  resource.addGlyph(glyph);
@@ -24977,8 +25135,10 @@ function decodeFontGlyphs(doc, resource, buf) {
24977
25135
  */
24978
25136
  var BinaryReader = class {
24979
25137
  _fs;
24980
- constructor(fs) {
25138
+ _limits;
25139
+ constructor(fs, options = {}) {
24981
25140
  this._fs = fs;
25141
+ this._limits = readLimits(options);
24982
25142
  }
24983
25143
  async read(filePath) {
24984
25144
  const doc = new Document();
@@ -25004,9 +25164,12 @@ var BinaryReader = class {
25004
25164
  outer.skip(20);
25005
25165
  let buf;
25006
25166
  if (compressed) {
25007
- const decompressed = inflateRaw_1(new Uint8Array(outer.buffer, outer.byteOffset + outer.pos, outer.byteLength - outer.pos));
25167
+ const decompressed = inflateRawWithLimits(new Uint8Array(outer.buffer, outer.byteOffset + outer.pos, outer.byteLength - outer.pos), this._limits);
25008
25168
  buf = new ByteBuffer(decompressed.buffer, 0, decompressed.byteLength);
25009
- } else buf = outer;
25169
+ } else {
25170
+ if (outer.byteLength - outer.pos > this._limits.maxDecompressedBytes) throw new Error(`FairyGUI binary data exceeds ${this._limits.maxDecompressedBytes} bytes.`);
25171
+ buf = outer;
25172
+ }
25010
25173
  buf.version = outer.version;
25011
25174
  const indexTablePos = buf.pos;
25012
25175
  const ver2 = buf.version >= 2;
@@ -25135,6 +25298,8 @@ var BinaryReader = class {
25135
25298
  ...getComponentExtras(res),
25136
25299
  _rawBinary: toRawBinarySlice(rawData)
25137
25300
  });
25301
+ res._markBinaryClean();
25302
+ doc._trackBinaryComponent();
25138
25303
  pkg.addResource(res);
25139
25304
  createdResource = res;
25140
25305
  break;
@@ -25286,7 +25451,7 @@ var BinaryReader = class {
25286
25451
  *
25287
25452
  * @internal
25288
25453
  */
25289
- var WriteBuffer = class {
25454
+ var WriteBuffer = class WriteBuffer {
25290
25455
  _buf;
25291
25456
  _view;
25292
25457
  _pos = 0;
@@ -25296,6 +25461,9 @@ var WriteBuffer = class {
25296
25461
  _strings;
25297
25462
  /** Raw custom strings written to block 5, keyed by string table index. */
25298
25463
  _customStrings;
25464
+ static _assertInteger(value, min, max, type) {
25465
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new RangeError(`${type} value is out of range: ${value}`);
25466
+ }
25299
25467
  constructor(initialSize = 4096, parent) {
25300
25468
  this._buf = new ArrayBuffer(initialSize);
25301
25469
  this._view = new DataView(this._buf);
@@ -25330,34 +25498,41 @@ var WriteBuffer = class {
25330
25498
  this._view = new DataView(this._buf);
25331
25499
  }
25332
25500
  writeUint8(v) {
25501
+ WriteBuffer._assertInteger(v, 0, 255, "uint8");
25333
25502
  this._ensure(1);
25334
25503
  this._view.setUint8(this._pos++, v);
25335
25504
  }
25336
25505
  writeInt8(v) {
25506
+ WriteBuffer._assertInteger(v, -128, 127, "int8");
25337
25507
  this._ensure(1);
25338
25508
  this._view.setInt8(this._pos++, v);
25339
25509
  }
25340
25510
  writeUint16(v) {
25511
+ WriteBuffer._assertInteger(v, 0, 65535, "uint16");
25341
25512
  this._ensure(2);
25342
25513
  this._view.setUint16(this._pos, v, false);
25343
25514
  this._pos += 2;
25344
25515
  }
25345
25516
  writeInt16(v) {
25517
+ WriteBuffer._assertInteger(v, -32768, 32767, "int16");
25346
25518
  this._ensure(2);
25347
25519
  this._view.setInt16(this._pos, v, false);
25348
25520
  this._pos += 2;
25349
25521
  }
25350
25522
  writeUint32(v) {
25523
+ WriteBuffer._assertInteger(v, 0, 4294967295, "uint32");
25351
25524
  this._ensure(4);
25352
25525
  this._view.setUint32(this._pos, v, false);
25353
25526
  this._pos += 4;
25354
25527
  }
25355
25528
  writeInt32(v) {
25529
+ WriteBuffer._assertInteger(v, -2147483648, 2147483647, "int32");
25356
25530
  this._ensure(4);
25357
25531
  this._view.setInt32(this._pos, v, false);
25358
25532
  this._pos += 4;
25359
25533
  }
25360
25534
  writeFloat32(v) {
25535
+ if (!Number.isFinite(v)) throw new RangeError(`float32 value must be finite: ${v}`);
25361
25536
  this._ensure(4);
25362
25537
  this._view.setFloat32(this._pos, v, false);
25363
25538
  this._pos += 4;
@@ -25368,6 +25543,7 @@ var WriteBuffer = class {
25368
25543
  /** Write a uint16-prefixed UTF-8 string. */
25369
25544
  writeUTFString(s) {
25370
25545
  const encoded = new TextEncoder().encode(s);
25546
+ if (encoded.byteLength > 65535) throw new RangeError(`UTF string exceeds uint16 byte length: ${encoded.byteLength}`);
25371
25547
  this.writeUint16(encoded.byteLength);
25372
25548
  this._ensure(encoded.byteLength);
25373
25549
  new Uint8Array(this._buf, this._pos, encoded.byteLength).set(encoded);
@@ -25394,6 +25570,7 @@ var WriteBuffer = class {
25394
25570
  const existing = this._stringMap.get(s);
25395
25571
  if (existing !== void 0) return existing;
25396
25572
  const index = this._strings.length;
25573
+ if (index >= 65533) throw new RangeError(`String table exceeds protocol index limit: ${index + 1}`);
25397
25574
  this._strings.push(s);
25398
25575
  this._stringMap.set(s, index);
25399
25576
  return index;
@@ -25430,6 +25607,7 @@ var WriteBuffer = class {
25430
25607
  if (!noCache) this.writeUint16(this.addString(s));
25431
25608
  else {
25432
25609
  const index = this._strings.length;
25610
+ if (index >= 65533) throw new RangeError(`String table exceeds protocol index limit: ${index + 1}`);
25433
25611
  this._strings.push(s);
25434
25612
  this.writeUint16(index);
25435
25613
  }
@@ -27236,7 +27414,7 @@ var BinaryWriter = class {
27236
27414
  const compExtras = res.getExtras();
27237
27415
  const extType = res.getExtensionType?.() ?? compExtras.extensionType;
27238
27416
  data.writeUint8(extType ? extTypeMap[extType] ?? 0 : 0);
27239
- if (compExtras?._rawBinary) data.writeBuffer(toUint8Array(compExtras._rawBinary));
27417
+ if (compExtras?._rawBinary && !res._isBinaryDirty()) data.writeBuffer(toUint8Array(compExtras._rawBinary));
27240
27418
  else {
27241
27419
  const encoded = encodeComponent(res, doc, pkg, version, data);
27242
27420
  data.writeBuffer(encoded);
@@ -27525,7 +27703,7 @@ function _encodeFontGlyphs(fntData, parentBuf) {
27525
27703
  for (const glyph of fntData.glyphs) {
27526
27704
  const glyphStart = buf.pos;
27527
27705
  buf.writeInt16(0);
27528
- buf.writeInt16(glyph.charId);
27706
+ buf.writeUint16(glyph.charId);
27529
27707
  buf.writeS(glyph.img);
27530
27708
  buf.writeInt32(glyph.x);
27531
27709
  buf.writeInt32(glyph.y);
@@ -27659,8 +27837,8 @@ var PlatformIO = class {
27659
27837
  async writeProject(doc, projectPath, options) {
27660
27838
  return new ProjectWriter(this.createFileSystem()).write(doc, projectPath, options);
27661
27839
  }
27662
- async readBinary(filePath) {
27663
- return new BinaryReader(this.createFileSystem()).read(filePath);
27840
+ async readBinary(filePath, options) {
27841
+ return new BinaryReader(this.createFileSystem(), options).read(filePath);
27664
27842
  }
27665
27843
  async writeBinary(doc, filePath, options) {
27666
27844
  return new BinaryWriter(this.createFileSystem()).write(doc, filePath, options);
@@ -30107,6 +30285,9 @@ const FGUI_TYPESCRIPT_BINDER_TEMPLATE = `{{generatedMark}}
30107
30285
  function formatPluginError(error) {
30108
30286
  return error instanceof Error ? error.message : String(error);
30109
30287
  }
30288
+ function shouldAbortPluginFailure(plugin) {
30289
+ return plugin.failureMode !== "warn";
30290
+ }
30110
30291
  //#endregion
30111
30292
  //#region ../functions/src/path-utils.ts
30112
30293
  function trimTrailingSlashes(value) {
@@ -30192,7 +30373,9 @@ async function publishCodeGeneration(doc, options) {
30192
30373
  handled = true;
30193
30374
  logger.info(`publish: Generated code using plugin "${plugin.name}"`);
30194
30375
  } catch (error) {
30195
- logger.warn(`publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`);
30376
+ const message = `publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`;
30377
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
30378
+ logger.warn(message);
30196
30379
  }
30197
30380
  }
30198
30381
  if (handled) return;
@@ -31574,7 +31757,9 @@ async function runPublishPluginHook(plugins, hook, doc, options) {
31574
31757
  try {
31575
31758
  await fn(doc, options);
31576
31759
  } catch (error) {
31577
- logger.warn(`publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`);
31760
+ const message = `publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`;
31761
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
31762
+ logger.warn(message);
31578
31763
  }
31579
31764
  }
31580
31765
  }
@@ -31866,15 +32051,9 @@ var NodeIO = class extends PlatformIO {
31866
32051
  },
31867
32052
  async readdir(dirPath) {
31868
32053
  const entries = await fs$1.readdir(dirPath, { withFileTypes: true });
31869
- return (await Promise.all(entries.map(async (entry) => {
31870
- if (entry.isDirectory()) return entry.name;
31871
- if (!entry.isSymbolicLink()) return null;
31872
- try {
31873
- return (await fs$1.stat(path$1.join(dirPath, entry.name))).isDirectory() ? entry.name : null;
31874
- } catch {
31875
- return null;
31876
- }
31877
- }))).filter((entry) => entry !== null);
32054
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
32055
+ if (symlink) throw new Error(`Symbolic links are not supported in project directories: ${path$1.join(dirPath, symlink.name)}`);
32056
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
31878
32057
  },
31879
32058
  async exists(filePath) {
31880
32059
  try {
@@ -31959,16 +32138,33 @@ async function loadPlugins(doc, pluginsDir) {
31959
32138
  for (const entry of entries) {
31960
32139
  if (!entry.isDirectory()) continue;
31961
32140
  const pluginDir = path.join(pluginsDir, entry.name);
32141
+ let manifest;
32142
+ try {
32143
+ manifest = await readPluginManifest(fs, path, pluginDir);
32144
+ } catch (error) {
32145
+ doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
32146
+ continue;
32147
+ }
32148
+ if (!manifest) continue;
32149
+ if (!manifest.main) {
32150
+ const error = /* @__PURE__ */ new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
32151
+ if (manifest.required) throw error;
32152
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${error.message}`);
32153
+ continue;
32154
+ }
31962
32155
  try {
31963
- const manifest = await readPluginManifest(fs, path, pluginDir);
31964
- if (!manifest) continue;
31965
32156
  const plugin = await loadPlugin(resolvePluginMain(path, pluginDir, manifest));
31966
32157
  plugins.push({
31967
32158
  name: manifest.name,
31968
- plugin
32159
+ plugin,
32160
+ failureMode: manifest.required ? "abort" : manifest.failureMode
31969
32161
  });
31970
32162
  } catch (error) {
31971
- doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
32163
+ if (!manifest.required && manifest.failureMode === "warn") {
32164
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${formatPluginError(error)}`);
32165
+ continue;
32166
+ }
32167
+ throw new Error(`publish: Failed to load plugin "${manifest.name}": ${formatPluginError(error)}`);
31972
32168
  }
31973
32169
  }
31974
32170
  return plugins;
@@ -31978,7 +32174,6 @@ async function readPluginManifest(fs, path, pluginDir) {
31978
32174
  const content = await fs.readFile(manifestPath, "utf-8");
31979
32175
  const manifest = JSON.parse(content);
31980
32176
  if (!manifest.name) throw new Error(`Codegen plugin at ${pluginDir} is missing package.json name.`);
31981
- if (!manifest.main) throw new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
31982
32177
  return manifest;
31983
32178
  }
31984
32179
  function resolvePluginMain(path, pluginDir, manifest) {
@@ -32043,6 +32238,77 @@ async function loadNodePublishPlugins(document, assetsPath) {
32043
32238
  if (!projectDir) return [];
32044
32239
  return loadPlugins(document, (await importNative$2("node:path")).join(projectDir, "plugins"));
32045
32240
  }
32241
+ async function publishToStagedOutput(output, run) {
32242
+ const [fs, path, { randomUUID }] = await Promise.all([
32243
+ importNative$2("node:fs/promises"),
32244
+ importNative$2("node:path"),
32245
+ importNative$2("node:crypto")
32246
+ ]);
32247
+ const target = path.resolve(output);
32248
+ const parent = path.dirname(target);
32249
+ const name = path.basename(target);
32250
+ const staging = path.join(parent, `.${name}.publish-${randomUUID()}`);
32251
+ const backup = path.join(parent, `.${name}.publish-backup-${randomUUID()}`);
32252
+ await fs.mkdir(parent, { recursive: true });
32253
+ let existed = false;
32254
+ try {
32255
+ const stat = await fs.lstat(target);
32256
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`publishNode: output must be a regular directory: ${target}`);
32257
+ await assertNoSymlinks(fs, path, target);
32258
+ existed = true;
32259
+ } catch (error) {
32260
+ if (error.code !== "ENOENT") throw error;
32261
+ }
32262
+ try {
32263
+ if (existed) await fs.cp(target, staging, {
32264
+ recursive: true,
32265
+ errorOnExist: true,
32266
+ force: false
32267
+ });
32268
+ else await fs.mkdir(staging);
32269
+ } catch (error) {
32270
+ await fs.rm(staging, {
32271
+ recursive: true,
32272
+ force: true
32273
+ });
32274
+ throw error;
32275
+ }
32276
+ try {
32277
+ await run(staging);
32278
+ } catch (error) {
32279
+ await fs.rm(staging, {
32280
+ recursive: true,
32281
+ force: true
32282
+ });
32283
+ throw error;
32284
+ }
32285
+ if (!existed) {
32286
+ await fs.rename(staging, target);
32287
+ return;
32288
+ }
32289
+ await fs.rename(target, backup);
32290
+ try {
32291
+ await fs.rename(staging, target);
32292
+ } catch (error) {
32293
+ await fs.rename(backup, target);
32294
+ await fs.rm(staging, {
32295
+ recursive: true,
32296
+ force: true
32297
+ });
32298
+ throw error;
32299
+ }
32300
+ await fs.rm(backup, {
32301
+ recursive: true,
32302
+ force: true
32303
+ }).catch(() => void 0);
32304
+ }
32305
+ async function assertNoSymlinks(fs, path, directory) {
32306
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
32307
+ const entryPath = path.join(directory, entry.name);
32308
+ if (entry.isSymbolicLink()) throw new Error(`publishNode: symbolic links are not supported in output directories: ${entryPath}`);
32309
+ if (entry.isDirectory()) await assertNoSymlinks(fs, path, entryPath);
32310
+ }
32311
+ }
32046
32312
  /**
32047
32313
  * Publish a FairyGUI project through the standard Node host adapter.
32048
32314
  *
@@ -32055,17 +32321,25 @@ async function publishNode(options) {
32055
32321
  const [fileSystem, assetsPath] = await Promise.all([createNodePublishFileSystem(), resolveNodeAssetsPath(document, configuredAssetsPath)]);
32056
32322
  const [encoder, plugins] = await Promise.all([configuredEncoder === void 0 ? loadSharpBackend() : Promise.resolve(configuredEncoder), configuredPlugins === void 0 ? loadNodePublishPlugins(document, assetsPath) : Promise.resolve(configuredPlugins)]);
32057
32323
  if (!encoder) throw new Error("publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.");
32058
- await document.transform(publish({
32059
- ...publishOptions,
32060
- basePath: assetsPath,
32061
- encoder,
32062
- atlas: {
32063
- ...atlas,
32064
- readFileRaw: fileSystem.readFileRaw
32065
- },
32066
- fs: fileSystem,
32067
- plugins
32068
- }));
32324
+ const run = async (output) => {
32325
+ await document.transform(publish({
32326
+ ...publishOptions,
32327
+ output,
32328
+ basePath: assetsPath,
32329
+ encoder,
32330
+ atlas: {
32331
+ ...atlas,
32332
+ readFileRaw: fileSystem.readFileRaw
32333
+ },
32334
+ fs: fileSystem,
32335
+ plugins
32336
+ }));
32337
+ };
32338
+ if (publishOptions.output) {
32339
+ await publishToStagedOutput(publishOptions.output, run);
32340
+ return;
32341
+ }
32342
+ await run(void 0);
32069
32343
  }
32070
32344
  //#endregion
32071
32345
  //#region ../functions/src/adapters/node/restore.ts
@@ -32364,7 +32638,7 @@ function registerValidateCommand(program) {
32364
32638
  //#region src/utils/package-version.ts
32365
32639
  const require$1 = createRequire(import.meta.url);
32366
32640
  function getInjectedPackageVersion() {
32367
- const version = "0.3.0";
32641
+ const version = "0.3.1";
32368
32642
  return typeof version === "string" && true ? version : null;
32369
32643
  }
32370
32644
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "FairyGUI Headless Authoring SDK — command-line interface.",
5
5
  "author": "OpenFairyGUI Contributors",
6
6
  "license": "MIT",
@@ -13,6 +13,9 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/OpenFairyGUI/OpenFairyGUI/issues"
15
15
  },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
16
19
  "type": "module",
17
20
  "bin": {
18
21
  "ofgui": "bin/cli.cjs",
@@ -33,13 +36,13 @@
33
36
  "restore"
34
37
  ],
35
38
  "devDependencies": {
36
- "@openfairygui/core": "0.3.0",
37
- "@openfairygui/functions": "0.3.0"
39
+ "@openfairygui/functions": "0.3.1",
40
+ "@openfairygui/core": "0.3.1"
38
41
  },
39
42
  "dependencies": {
40
43
  "commander": "^14.0.2",
41
44
  "jiti": "^2.7.0",
42
- "@openfairygui/backend": "0.3.0"
45
+ "@openfairygui/backend": "0.3.1"
43
46
  },
44
47
  "optionalDependencies": {
45
48
  "sharp": ">=0.33.0"