@openfairygui/cli 0.1.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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@openfairygui/cli",
3
+ "version": "0.1.0",
4
+ "description": "FairyGUI Headless Authoring SDK — command-line interface.",
5
+ "author": "OpenFairyGUI Contributors",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/OpenFairyGUI/OpenFairyGUI.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/OpenFairyGUI/OpenFairyGUI#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/OpenFairyGUI/OpenFairyGUI/issues"
15
+ },
16
+ "type": "module",
17
+ "bin": {
18
+ "ofgui": "bin/cli.cjs",
19
+ "openfairygui": "bin/cli.cjs"
20
+ },
21
+ "scripts": {
22
+ "build": "tsdown src/cli.ts --format esm --platform node --no-dts --external sharp"
23
+ },
24
+ "files": [
25
+ "dist/",
26
+ "bin/",
27
+ "src/"
28
+ ],
29
+ "keywords": [
30
+ "fairygui",
31
+ "cli",
32
+ "ui",
33
+ "headless",
34
+ "authoring",
35
+ "publish",
36
+ "restore"
37
+ ],
38
+ "devDependencies": {
39
+ "@openfairygui/core": "workspace:*",
40
+ "@openfairygui/functions": "workspace:*"
41
+ },
42
+ "optionalDependencies": {
43
+ "sharp": ">=0.33.0"
44
+ }
45
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,403 @@
1
+ import {
2
+ NodeIO,
3
+ ProjectType,
4
+ type RestoreImageCropInput,
5
+ type RestoreImageCropper,
6
+ type RestoreImageExtractInput,
7
+ type RestoreImageExtractor,
8
+ } from '@openfairygui/core';
9
+ import { inspect, publish, resolvePublishOptions, type InspectReport, type PublishOptions } from '@openfairygui/functions';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { parseArgs } from 'node:util';
13
+
14
+ const HELP = `
15
+ ofgui — FairyGUI Headless Authoring CLI
16
+
17
+ Alias:
18
+ openfairygui
19
+
20
+ Commands:
21
+ inspect <project-dir> Show project contents report
22
+ publish <project-dir> --output <dir> [options] Publish project to binary outputs and configured generated code
23
+ restore <release-dir> --output <dir> [options] Restore a FairyGUI project from published binaries
24
+
25
+ Publish options:
26
+ --output, -o <dir> Output directory (required)
27
+ --compressed Compress binary data (overrides project setting)
28
+ --packages <a,b,c> Only publish specific packages (comma-separated)
29
+ --branch <name> Active branch used by "主干合并活跃分支"; omit for main branch
30
+ --project-type <name|id> Override project type (for example: unity, layabox, cocoscreator, 0, 4, 3)
31
+
32
+ Restore options:
33
+ --output, -o <dir> Output project directory (required)
34
+ --packages <a,b,c> Only restore specific packages (comma-separated)
35
+ --force Overwrite a non-empty output directory
36
+ --project-type <name|id> Override restored project type; default is unity
37
+
38
+ Options:
39
+ --help, -h Show this help
40
+ --version, -v Show version
41
+
42
+ Input can be a .fairy file or a project root directory (auto-discovers .fairy file).
43
+ File extension and binary format are read from project settings.
44
+ `;
45
+
46
+ /** Resolve input to a .fairy file path. Accepts a directory or a .fairy file. */
47
+ async function resolveFairyPath(input: string): Promise<string> {
48
+ const resolved = path.resolve(input);
49
+ const stat = await fs.stat(resolved);
50
+
51
+ if (stat.isFile() && resolved.endsWith('.fairy')) {
52
+ return resolved;
53
+ }
54
+
55
+ if (stat.isDirectory()) {
56
+ // Scan for *.fairy in the directory
57
+ const entries = await fs.readdir(resolved);
58
+ const fairyFiles = entries.filter((e) => e.endsWith('.fairy'));
59
+ if (fairyFiles.length === 1) {
60
+ return path.join(resolved, fairyFiles[0]);
61
+ }
62
+ if (fairyFiles.length > 1) {
63
+ throw new Error(`Multiple .fairy files found in ${resolved}: ${fairyFiles.join(', ')}. Please specify one.`);
64
+ }
65
+ throw new Error(`No .fairy file found in ${resolved}`);
66
+ }
67
+
68
+ throw new Error(`Input is not a .fairy file or directory: ${resolved}`);
69
+ }
70
+
71
+ async function main(): Promise<void> {
72
+ const args = process.argv.slice(2);
73
+
74
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
75
+ console.log(HELP);
76
+ return;
77
+ }
78
+
79
+ if (args.includes('--version') || args.includes('-v')) {
80
+ console.log('0.1.0');
81
+ return;
82
+ }
83
+
84
+ const command = args[0];
85
+ const rest = args.slice(1);
86
+
87
+ switch (command) {
88
+ case 'inspect':
89
+ await cmdInspect(rest);
90
+ break;
91
+ case 'publish':
92
+ await cmdPublish(rest);
93
+ break;
94
+ case 'restore':
95
+ await cmdRestore(rest);
96
+ break;
97
+ default:
98
+ console.error(`Unknown command: ${command}\n`);
99
+ console.log(HELP);
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ interface RestoreImageProcessors {
105
+ cropImage: RestoreImageCropper;
106
+ extractImage: RestoreImageExtractor;
107
+ }
108
+
109
+ async function createRestoreImageProcessors(): Promise<RestoreImageProcessors> {
110
+ let sharp: any;
111
+ try {
112
+ const mod = await import('sharp');
113
+ sharp = mod.default ?? mod;
114
+ } catch {
115
+ throw new Error('restore: sharp is required to crop atlas images. Install it with: pnpm add sharp');
116
+ }
117
+
118
+ async function extractImage(input: RestoreImageExtractInput): Promise<Uint8Array> {
119
+ const targetPath = (input as RestoreImageCropInput).outputPath ?? input.sourcePath;
120
+ let image = sharp(input.sourcePath).extract({
121
+ left: input.left,
122
+ top: input.top,
123
+ width: input.width,
124
+ height: input.height,
125
+ });
126
+ if (input.rotated) image = image.rotate(90);
127
+ const { data, info } = await image.png().toBuffer({ resolveWithObject: true });
128
+ const needsOriginalCanvas = input.expectedWidth > 0 && input.expectedHeight > 0 && (
129
+ input.offsetX !== 0
130
+ || input.offsetY !== 0
131
+ || info.width !== input.expectedWidth
132
+ || info.height !== input.expectedHeight
133
+ );
134
+
135
+ if (needsOriginalCanvas) {
136
+ if (
137
+ input.offsetX < 0
138
+ || input.offsetY < 0
139
+ || input.offsetX + info.width > input.expectedWidth
140
+ || input.offsetY + info.height > input.expectedHeight
141
+ ) {
142
+ throw new Error(
143
+ `restore: Cropped image does not fit original canvas for ${input.outputPath}: `
144
+ + `crop ${info.width}x${info.height} at ${input.offsetX},${input.offsetY}, `
145
+ + `canvas ${input.expectedWidth}x${input.expectedHeight}`,
146
+ );
147
+ }
148
+ const composed = await sharp({
149
+ create: {
150
+ width: input.expectedWidth,
151
+ height: input.expectedHeight,
152
+ channels: 4,
153
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
154
+ },
155
+ })
156
+ .composite([{ input: data, left: input.offsetX, top: input.offsetY }])
157
+ .png()
158
+ .toBuffer({ resolveWithObject: true });
159
+ if (
160
+ input.expectedWidth > 0
161
+ && input.expectedHeight > 0
162
+ && (composed.info.width !== input.expectedWidth || composed.info.height !== input.expectedHeight)
163
+ ) {
164
+ throw new Error(
165
+ `restore: Cropped image size mismatch for ${targetPath}: `
166
+ + `expected ${input.expectedWidth}x${input.expectedHeight}, got ${composed.info.width}x${composed.info.height}`,
167
+ );
168
+ }
169
+ return composed.data;
170
+ }
171
+
172
+ if (
173
+ input.expectedWidth > 0
174
+ && input.expectedHeight > 0
175
+ && (info.width !== input.expectedWidth || info.height !== input.expectedHeight)
176
+ ) {
177
+ throw new Error(
178
+ `restore: Cropped image size mismatch for ${targetPath}: `
179
+ + `expected ${input.expectedWidth}x${input.expectedHeight}, got ${info.width}x${info.height}`,
180
+ );
181
+ }
182
+ return data;
183
+ }
184
+
185
+ return {
186
+ extractImage,
187
+ cropImage: async (input: RestoreImageCropInput): Promise<void> => {
188
+ await fs.mkdir(path.dirname(input.outputPath), { recursive: true });
189
+ await fs.writeFile(input.outputPath, await extractImage(input));
190
+ },
191
+ };
192
+ }
193
+
194
+ async function cmdInspect(args: string[]): Promise<void> {
195
+ if (args.length === 0) {
196
+ console.error('Usage: ofgui inspect <project-dir>');
197
+ process.exit(1);
198
+ }
199
+
200
+ const fairyPath = await resolveFairyPath(args[0]);
201
+ console.log(`Project: ${fairyPath}\n`);
202
+
203
+ const io = new NodeIO();
204
+ const doc = await io.readProject(fairyPath);
205
+ const report = inspect(doc);
206
+
207
+ printReport(report);
208
+ }
209
+
210
+ function printReport(report: InspectReport): void {
211
+ console.log(`ID: ${report.projectId}`);
212
+ console.log(`Type: ${report.projectType}, Version: ${report.version}`);
213
+ console.log(`\nPackages: ${report.totals.packages}`);
214
+ console.log(` Images: ${report.totals.images}`);
215
+ console.log(` Sounds: ${report.totals.sounds}`);
216
+ console.log(` Fonts: ${report.totals.fonts}`);
217
+ console.log(` MovieClips: ${report.totals.movieClips}`);
218
+ console.log(` Components: ${report.totals.components}`);
219
+ console.log(` DisplayObjs: ${report.totals.displayObjects}`);
220
+ console.log(` Gears: ${report.totals.gears}`);
221
+ console.log(` Controllers: ${report.totals.controllers}`);
222
+ console.log(` Transitions: ${report.totals.transitions}`);
223
+
224
+ console.log('\nPackage details:');
225
+ for (const pkg of report.packages) {
226
+ const res = pkg.resources;
227
+ console.log(` ${pkg.name} (${pkg.id}): ${res.images.count} img, ${res.sounds.count} snd, ${res.fonts.count} font, ${res.components.count} comp`);
228
+ }
229
+ }
230
+
231
+ function parseProjectType(value: string | undefined): number | undefined {
232
+ if (!value) return undefined;
233
+ const trimmed = value.trim();
234
+ if (trimmed === '') return undefined;
235
+ if (/^\d+$/u.test(trimmed)) return Number(trimmed);
236
+ const normalized = trimmed.toLowerCase();
237
+ const map: Record<string, number> = {
238
+ unity: ProjectType.Unity,
239
+ flash: ProjectType.Flash,
240
+ starling: ProjectType.Starling,
241
+ cocoscreator: ProjectType.CocosCreator,
242
+ cocos: ProjectType.CocosCreator,
243
+ layabox: ProjectType.LayaBox,
244
+ laya: ProjectType.LayaBox,
245
+ egret: ProjectType.Egret,
246
+ haxe: ProjectType.Haxe,
247
+ pixi: ProjectType.Pixi,
248
+ libgdx: ProjectType.LibGDX,
249
+ unreal: ProjectType.Unreal,
250
+ cryengine: ProjectType.CryEngine,
251
+ monogame: ProjectType.MonoGame,
252
+ vision: ProjectType.Vision,
253
+ };
254
+ const resolved = map[normalized];
255
+ if (resolved === undefined) {
256
+ throw new Error(`Unknown project type: ${value}. Use a numeric id or one of: ${Object.keys(map).join(', ')}`);
257
+ }
258
+ return resolved;
259
+ }
260
+
261
+ async function cmdRestore(args: string[]): Promise<void> {
262
+ const { values, positionals } = parseArgs({
263
+ args,
264
+ options: {
265
+ output: { type: 'string', short: 'o' },
266
+ packages: { type: 'string' },
267
+ force: { type: 'boolean' },
268
+ 'project-type': { type: 'string' },
269
+ },
270
+ allowPositionals: true,
271
+ });
272
+
273
+ if (positionals.length === 0 || !values.output) {
274
+ console.error('Usage: ofgui restore <release-dir> --output <dir> [--packages a,b,c] [--force]');
275
+ process.exit(1);
276
+ }
277
+
278
+ const releaseDir = path.resolve(positionals[0]);
279
+ const outputDir = path.resolve(values.output);
280
+ const pkgFilter = values.packages ? values.packages.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
281
+ const projectType = parseProjectType(values['project-type']);
282
+ const { cropImage, extractImage } = await createRestoreImageProcessors();
283
+
284
+ console.log(`Restoring published FairyGUI project: ${releaseDir}`);
285
+ const io = new NodeIO();
286
+ const result = await io.restorePublishedProject(releaseDir, outputDir, {
287
+ packages: pkgFilter,
288
+ force: values.force,
289
+ projectType,
290
+ cropImage,
291
+ extractImage,
292
+ });
293
+
294
+ const packages = result.document.getRoot().listPackages();
295
+ console.log(`\nDone! Output: ${result.projectPath}`);
296
+ console.log(`Packages: ${packages.map((pkg) => pkg.getName()).join(', ')}`);
297
+ for (const warning of result.warnings) {
298
+ console.warn(`Warning: ${warning}`);
299
+ }
300
+ }
301
+
302
+ async function cmdPublish(args: string[]): Promise<void> {
303
+ const { values, positionals } = parseArgs({
304
+ args,
305
+ options: {
306
+ output: { type: 'string', short: 'o' },
307
+ compressed: { type: 'boolean' },
308
+ packages: { type: 'string' },
309
+ branch: { type: 'string' },
310
+ 'project-type': { type: 'string' },
311
+ },
312
+ allowPositionals: true,
313
+ });
314
+
315
+ if (positionals.length === 0 || !values.output) {
316
+ console.error('Usage: ofgui publish <project-dir> --output <dir> [--compressed] [--packages a,b,c] [--branch name]');
317
+ process.exit(1);
318
+ }
319
+
320
+ const fairyPath = await resolveFairyPath(positionals[0]);
321
+ const projectDir = path.dirname(fairyPath);
322
+ const outputDir = path.resolve(values.output);
323
+
324
+ console.log(`Reading project: ${fairyPath}`);
325
+ const io = new NodeIO();
326
+ const doc = await io.readProject(fairyPath);
327
+ const projectType = parseProjectType(values['project-type']);
328
+ if (projectType !== undefined) {
329
+ doc.getRoot().setProjectType(projectType);
330
+ }
331
+
332
+ const pkgFilter = values.packages ? values.packages.split(',').map((s) => s.trim()) : undefined;
333
+ const resolved = resolvePublishOptions(doc, {
334
+ compressed: values.compressed,
335
+ packages: pkgFilter,
336
+ });
337
+
338
+ console.log(`Settings: ext=${resolved.fileExtension}, compressed=${resolved.compressed}`);
339
+ if (values.branch) {
340
+ console.log(`Active branch: ${values.branch}`);
341
+ }
342
+
343
+ const atlasConfig: NonNullable<PublishOptions['atlas']> = {
344
+ ...resolved.atlas,
345
+ readFileRaw: async (filePath: string) => {
346
+ const buf = await fs.readFile(filePath);
347
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
348
+ },
349
+ };
350
+
351
+ // Try to load sharp for atlas image compositing
352
+ let encoder: PublishOptions['encoder'];
353
+ try {
354
+ const sharp = await import('sharp');
355
+ encoder = sharp.default ?? sharp;
356
+ console.log('Sharp loaded — atlas PNGs will be generated.');
357
+ } catch {
358
+ console.log('Sharp not available — atlas PNGs will NOT be generated (layout only).');
359
+ console.log(' Install sharp to enable: pnpm add sharp');
360
+ }
361
+
362
+ const publishFs: NonNullable<PublishOptions['fs']> = {
363
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
364
+ const buf = await fs.readFile(filePath);
365
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
366
+ },
367
+ async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
368
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
369
+ await fs.writeFile(filePath, data);
370
+ },
371
+ async mkdir(dirPath: string): Promise<void> {
372
+ await fs.mkdir(dirPath, { recursive: true });
373
+ },
374
+ async readdir(dirPath: string): Promise<string[]> {
375
+ return fs.readdir(dirPath);
376
+ },
377
+ async deleteFile(filePath: string): Promise<void> {
378
+ await fs.rm(filePath, { force: true });
379
+ },
380
+ join(...paths: string[]): string {
381
+ return path.join(...paths);
382
+ },
383
+ };
384
+
385
+ await doc.transform(publish({
386
+ output: outputDir,
387
+ compressed: resolved.compressed,
388
+ fileExtension: resolved.fileExtension,
389
+ packages: resolved.packages,
390
+ fs: publishFs,
391
+ encoder,
392
+ basePath: path.join(projectDir, 'assets'),
393
+ atlas: atlasConfig,
394
+ branch: values.branch,
395
+ }));
396
+
397
+ console.log(`\nDone! Output: ${outputDir}`);
398
+ }
399
+
400
+ main().catch((err) => {
401
+ console.error(err);
402
+ process.exit(1);
403
+ });