@openfairygui/functions 0.2.0-alpha.13 → 0.2.0-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/dist/{codegen-C7PPZrB3.d.cts → codegen-B8ZM1F4j.d.cts} +12 -11
- package/dist/{codegen-CEnmtjBP.d.ts → codegen-CfbDHuFt.d.ts} +12 -11
- package/dist/index.cjs +152 -56
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +152 -56
- package/dist/node.cjs +4 -4
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +4 -4
- package/dist/{publish-CtCXsMdj.cjs → publish-D7268_u4.cjs} +59 -32
- package/dist/{publish-Bl-GK9kt.js → publish-DsPK0SJ1.js} +59 -32
- package/dist/web.cjs +1 -1
- package/dist/web.js +1 -1
- package/package.json +2 -2
- package/src/adapters/node/publish.ts +6 -5
- package/src/adapters/web/publish.ts +1 -0
- package/src/atlas.ts +80 -17
- package/src/publish.ts +57 -49
- package/src/restore.ts +177 -68
package/src/publish.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type BinaryWriterOptions,
|
|
3
2
|
BinaryWriter,
|
|
3
|
+
type BinaryWriterOptions,
|
|
4
4
|
type Component,
|
|
5
|
-
type DragonBonesResource,
|
|
6
5
|
type Document,
|
|
6
|
+
type DragonBonesResource,
|
|
7
7
|
type FileSystem,
|
|
8
8
|
type FontResource,
|
|
9
9
|
type ImageResource,
|
|
@@ -11,12 +11,11 @@ import {
|
|
|
11
11
|
type MovieClipResource,
|
|
12
12
|
type Package,
|
|
13
13
|
ProjectType,
|
|
14
|
-
type SpineResource,
|
|
15
14
|
type SoundResource,
|
|
15
|
+
type SpineResource,
|
|
16
16
|
type Transform,
|
|
17
17
|
} from '@openfairygui/core';
|
|
18
|
-
import {
|
|
19
|
-
import { atlas, type AtlasOptions } from './atlas.js';
|
|
18
|
+
import { type AtlasOptions, atlas } from './atlas.js';
|
|
20
19
|
import { publishCodeGeneration, resolveProjectBasePath } from './codegen.js';
|
|
21
20
|
import { formatPluginError, type LoadedPlugin } from './plugins/types.js';
|
|
22
21
|
import type { AtlasRasterBackend, PublishFileSystem } from './publish/contracts.js';
|
|
@@ -26,6 +25,7 @@ import type {
|
|
|
26
25
|
PackagePublishArtifactsExtras,
|
|
27
26
|
RootProjectSettings,
|
|
28
27
|
} from './shared-types.js';
|
|
28
|
+
import { createTransform } from './utils.js';
|
|
29
29
|
|
|
30
30
|
export interface PublishOptions {
|
|
31
31
|
/**
|
|
@@ -47,7 +47,8 @@ export interface PublishOptions {
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Raster backend for atlas image compositing.
|
|
50
|
-
*
|
|
50
|
+
* Required when a filesystem-backed publish has packable resources.
|
|
51
|
+
* Without a filesystem, publish remains an explicit layout-only transform.
|
|
51
52
|
*/
|
|
52
53
|
encoder?: AtlasRasterBackend;
|
|
53
54
|
|
|
@@ -69,8 +70,9 @@ export interface PublishOptions {
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* FileSystem abstraction for writing output files.
|
|
72
|
-
* Required for actual file output.
|
|
73
|
-
* is
|
|
73
|
+
* Required for actual file output. Calling publish with a resolved output
|
|
74
|
+
* directory but no filesystem is rejected; omit output entirely for a
|
|
75
|
+
* layout-only transform.
|
|
74
76
|
*/
|
|
75
77
|
fs?: PublishFileSystem;
|
|
76
78
|
|
|
@@ -1030,7 +1032,6 @@ async function exportPackageSounds(
|
|
|
1030
1032
|
basePath: string | undefined,
|
|
1031
1033
|
fs: PublishFileSystem,
|
|
1032
1034
|
readFileRaw: PublishFileSystem['readFileRaw'] | undefined,
|
|
1033
|
-
logger: Document['getLogger'] extends () => infer T ? T : never,
|
|
1034
1035
|
): Promise<void> {
|
|
1035
1036
|
const publishedResourceIds = getAnnotatedPublishedResourceIds(pkg);
|
|
1036
1037
|
if (publishedResourceIds.size === 0) return;
|
|
@@ -1039,8 +1040,8 @@ async function exportPackageSounds(
|
|
|
1039
1040
|
return isSoundResource(resource) && publishedResourceIds.has(resource.getId());
|
|
1040
1041
|
});
|
|
1041
1042
|
if (hasPublishedSound) {
|
|
1042
|
-
|
|
1043
|
-
`publish: Sound resources in package "${pkg.getName()}"
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`publish: Sound resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`,
|
|
1044
1045
|
);
|
|
1045
1046
|
}
|
|
1046
1047
|
return;
|
|
@@ -1058,7 +1059,7 @@ async function exportPackageSounds(
|
|
|
1058
1059
|
const data = await readFileRaw(sourcePath);
|
|
1059
1060
|
await fs.writeFileRaw(targetPath, data);
|
|
1060
1061
|
} catch {
|
|
1061
|
-
|
|
1062
|
+
throw new Error(`publish: Could not export sound "${resource.getId()}" from package "${pkg.getName()}".`);
|
|
1062
1063
|
}
|
|
1063
1064
|
}
|
|
1064
1065
|
}
|
|
@@ -1069,7 +1070,6 @@ async function exportPackageExternalResources(
|
|
|
1069
1070
|
basePath: string | undefined,
|
|
1070
1071
|
fs: PublishFileSystem,
|
|
1071
1072
|
readFileRaw: PublishFileSystem['readFileRaw'] | undefined,
|
|
1072
|
-
logger: Document['getLogger'] extends () => infer T ? T : never,
|
|
1073
1073
|
): Promise<void> {
|
|
1074
1074
|
const exportedResourceIds = getAnnotatedExportedResourceIds(pkg);
|
|
1075
1075
|
const skeletonDependencyImageIds = getPublishedSkeletonDependencyImageIds(pkg, exportedResourceIds);
|
|
@@ -1083,8 +1083,8 @@ async function exportPackageExternalResources(
|
|
|
1083
1083
|
);
|
|
1084
1084
|
});
|
|
1085
1085
|
if (hasPublishedExternal) {
|
|
1086
|
-
|
|
1087
|
-
`publish: External resources in package "${pkg.getName()}"
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`publish: External resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`,
|
|
1088
1088
|
);
|
|
1089
1089
|
}
|
|
1090
1090
|
return;
|
|
@@ -1115,7 +1115,7 @@ async function exportPackageExternalResources(
|
|
|
1115
1115
|
const data = await readFileRaw(sourcePath);
|
|
1116
1116
|
await fs.writeFileRaw(targetPath, data);
|
|
1117
1117
|
} catch {
|
|
1118
|
-
|
|
1118
|
+
throw new Error(
|
|
1119
1119
|
`publish: Could not export external resource "${resource.getId()}" from package "${pkg.getName()}".`,
|
|
1120
1120
|
);
|
|
1121
1121
|
}
|
|
@@ -1134,18 +1134,17 @@ async function exportPackageExternalResources(
|
|
|
1134
1134
|
* `publishNode()` or `publishBrowser()` through their dedicated entries.
|
|
1135
1135
|
*
|
|
1136
1136
|
* ```ts
|
|
1137
|
-
* import
|
|
1138
|
-
*
|
|
1139
|
-
* const doc = await
|
|
1137
|
+
* import { NodeIO } from '@openfairygui/core/node';
|
|
1138
|
+
* import { publishNode } from '@openfairygui/functions/node';
|
|
1139
|
+
* const doc = await new NodeIO().readProject('./project.fairy');
|
|
1140
1140
|
*
|
|
1141
|
-
* await
|
|
1141
|
+
* await publishNode({
|
|
1142
|
+
* document: doc,
|
|
1142
1143
|
* output: './release/',
|
|
1143
1144
|
* compressed: true,
|
|
1144
|
-
*
|
|
1145
|
-
* basePath: './assets/',
|
|
1145
|
+
* assetsPath: './assets/',
|
|
1146
1146
|
* fileExtension: 'bytes',
|
|
1147
|
-
*
|
|
1148
|
-
* }));
|
|
1147
|
+
* });
|
|
1149
1148
|
* ```
|
|
1150
1149
|
*/
|
|
1151
1150
|
export function publish(options: PublishOptions): Transform {
|
|
@@ -1238,6 +1237,30 @@ export function publish(options: PublishOptions): Transform {
|
|
|
1238
1237
|
});
|
|
1239
1238
|
|
|
1240
1239
|
const publishPackage = async (plan: ResolvedPackagePublishPlan, writerFs: FileSystem, packageIndex: number) => {
|
|
1240
|
+
if (options.fs && !plan.outputDir) {
|
|
1241
|
+
throw new Error(
|
|
1242
|
+
'publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.',
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (options.fs) {
|
|
1247
|
+
await options.fs.mkdir(plan.outputDir!);
|
|
1248
|
+
await exportPackageSounds(
|
|
1249
|
+
plan.pkg,
|
|
1250
|
+
plan.outputDir!,
|
|
1251
|
+
options.basePath,
|
|
1252
|
+
options.fs,
|
|
1253
|
+
options.atlas?.readFileRaw ?? options.fs.readFileRaw,
|
|
1254
|
+
);
|
|
1255
|
+
await exportPackageExternalResources(
|
|
1256
|
+
plan.pkg,
|
|
1257
|
+
plan.outputDir!,
|
|
1258
|
+
options.basePath,
|
|
1259
|
+
options.fs,
|
|
1260
|
+
options.atlas?.readFileRaw ?? options.fs.readFileRaw,
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1241
1264
|
const atlasRuntimeOptions = resolvePublishAtlasRuntimeOptions(plan.fileExtension);
|
|
1242
1265
|
await atlas({
|
|
1243
1266
|
...plan.atlas,
|
|
@@ -1248,20 +1271,14 @@ export function publish(options: PublishOptions): Transform {
|
|
|
1248
1271
|
outputPath: options.fs ? plan.outputDir : undefined,
|
|
1249
1272
|
mkdir: options.fs ? options.fs.mkdir : undefined,
|
|
1250
1273
|
readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
|
|
1274
|
+
strictOutput: options.fs !== undefined,
|
|
1251
1275
|
packages: [plan.pkg.getName()],
|
|
1252
1276
|
...atlasRuntimeOptions,
|
|
1253
1277
|
})(doc);
|
|
1254
1278
|
|
|
1255
1279
|
if (!options.fs) return;
|
|
1256
|
-
if (!plan.outputDir) {
|
|
1257
|
-
throw new Error(
|
|
1258
|
-
'publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.',
|
|
1259
|
-
);
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
await options.fs.mkdir(plan.outputDir);
|
|
1263
1280
|
|
|
1264
|
-
const filePath = options.fs.join(plan.outputDir
|
|
1281
|
+
const filePath = options.fs.join(plan.outputDir!, plan.fileName);
|
|
1265
1282
|
const bwOptions: BinaryWriterOptions = {
|
|
1266
1283
|
compressed: plan.compressed,
|
|
1267
1284
|
packageIndex,
|
|
@@ -1269,22 +1286,6 @@ export function publish(options: PublishOptions): Transform {
|
|
|
1269
1286
|
|
|
1270
1287
|
const bw = new BinaryWriter(writerFs);
|
|
1271
1288
|
await bw.write(doc, filePath, bwOptions);
|
|
1272
|
-
await exportPackageSounds(
|
|
1273
|
-
plan.pkg,
|
|
1274
|
-
plan.outputDir,
|
|
1275
|
-
options.basePath,
|
|
1276
|
-
options.fs,
|
|
1277
|
-
options.atlas?.readFileRaw ?? options.fs.readFileRaw,
|
|
1278
|
-
logger,
|
|
1279
|
-
);
|
|
1280
|
-
await exportPackageExternalResources(
|
|
1281
|
-
plan.pkg,
|
|
1282
|
-
plan.outputDir,
|
|
1283
|
-
options.basePath,
|
|
1284
|
-
options.fs,
|
|
1285
|
-
options.atlas?.readFileRaw ?? options.fs.readFileRaw,
|
|
1286
|
-
logger,
|
|
1287
|
-
);
|
|
1288
1289
|
|
|
1289
1290
|
logger.info(`publish: Written ${plan.fileName}`);
|
|
1290
1291
|
};
|
|
@@ -1332,8 +1333,15 @@ export function publish(options: PublishOptions): Transform {
|
|
|
1332
1333
|
const plans = allPackages.map((pkg) => resolvePackagePublishPlan(pkg, resolved, projectBasePath));
|
|
1333
1334
|
|
|
1334
1335
|
if (!options.fs) {
|
|
1336
|
+
const outputPlan = plans.find((plan) => !!plan.outputDir);
|
|
1337
|
+
if (outputPlan) {
|
|
1338
|
+
throw new Error(
|
|
1339
|
+
`publish: Output for package "${outputPlan.pkg.getName()}" requires a filesystem. ` +
|
|
1340
|
+
'Omit output and publish paths to run a layout-only transform.',
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1335
1343
|
logger.info(
|
|
1336
|
-
`publish:
|
|
1344
|
+
`publish: Layout computed for ${allPackages.length} package(s); no output directory was requested.`,
|
|
1337
1345
|
);
|
|
1338
1346
|
const noopWriterFs = toBinaryWriterFileSystem(createNoopPublishFs());
|
|
1339
1347
|
for (const plan of plans) {
|
package/src/restore.ts
CHANGED
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
type Document,
|
|
4
4
|
type FileSystem,
|
|
5
5
|
generateId,
|
|
6
|
+
type Package,
|
|
6
7
|
ProjectType,
|
|
7
8
|
ProjectWriter,
|
|
8
|
-
type Package,
|
|
9
9
|
} from '@openfairygui/core';
|
|
10
10
|
|
|
11
11
|
export interface RestoreImageCropInput {
|
|
@@ -45,7 +45,8 @@ export interface RestoreFileSystem extends Pick<FileSystem, 'readFile' | 'readFi
|
|
|
45
45
|
readdir(path: string): Promise<string[]>;
|
|
46
46
|
isFile(path: string): Promise<boolean>;
|
|
47
47
|
resolvePath(path: string): string | Promise<string>;
|
|
48
|
-
rm
|
|
48
|
+
rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
|
49
|
+
rename(from: string, to: string): Promise<void>;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface RestoreOptions {
|
|
@@ -178,10 +179,23 @@ const TRANSPARENT_PNG_1X1 = Uint8Array.from([
|
|
|
178
179
|
66, 96, 130,
|
|
179
180
|
]);
|
|
180
181
|
|
|
182
|
+
function assertSafeRestoreSegment(value: string, label: string): void {
|
|
183
|
+
if (!value || value === '.' || value === '..' || value.includes('\0') || /[\\/:]/u.test(value)) {
|
|
184
|
+
throw new Error(`restore: Invalid ${label} "${value}".`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
181
188
|
function normalizeVirtualPath(path: string | undefined): string {
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
184
|
-
|
|
189
|
+
const raw = (path ?? '').trim();
|
|
190
|
+
if (!raw || raw === '/') return '';
|
|
191
|
+
if (raw.includes('\0') || raw.startsWith('\\') || raw.startsWith('//') || /^[a-z]:/iu.test(raw)) {
|
|
192
|
+
throw new Error(`restore: Invalid resource path "${raw}".`);
|
|
193
|
+
}
|
|
194
|
+
const segments = raw.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
195
|
+
if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes(':'))) {
|
|
196
|
+
throw new Error(`restore: Invalid resource path "${raw}".`);
|
|
197
|
+
}
|
|
198
|
+
return segments.join('/');
|
|
185
199
|
}
|
|
186
200
|
|
|
187
201
|
function resourceFileName(resource: RestorableResource): string {
|
|
@@ -438,10 +452,10 @@ function normalizeComparablePath(value: string): string {
|
|
|
438
452
|
return comparable.toLowerCase();
|
|
439
453
|
}
|
|
440
454
|
|
|
441
|
-
function
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
return
|
|
455
|
+
function isPathWithin(root: string, candidate: string): boolean {
|
|
456
|
+
const normalizedRoot = normalizeComparablePath(root);
|
|
457
|
+
const normalizedCandidate = normalizeComparablePath(candidate);
|
|
458
|
+
return normalizedCandidate.startsWith(`${normalizedRoot}/`);
|
|
445
459
|
}
|
|
446
460
|
|
|
447
461
|
function basename(filePath: string): string {
|
|
@@ -450,56 +464,56 @@ function basename(filePath: string): string {
|
|
|
450
464
|
return match?.[1] ?? '';
|
|
451
465
|
}
|
|
452
466
|
|
|
453
|
-
function
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
467
|
+
function normalizeRestoreOutputDir(output: string): string {
|
|
468
|
+
const normalized = trimTrailingSlashes(output);
|
|
469
|
+
const name = basename(normalized);
|
|
470
|
+
if (!normalized || /\.fairy$/i.test(normalized) || !name || name === '.' || name === '..' || /^[a-z]:$/iu.test(name)) {
|
|
471
|
+
throw new Error('restore: Output must be a non-root project directory, not a .fairy file.');
|
|
472
|
+
}
|
|
473
|
+
return normalized;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function resolveOutputProjectPath(outputDir: string, fs: Pick<RestoreFileSystem, 'join'>): string {
|
|
477
|
+
return fs.join(outputDir, `${basename(outputDir)}.fairy`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function resolvePathForContainment(filePath: string, fs: RestoreFileSystem): Promise<string> {
|
|
481
|
+
const missingSegments: string[] = [];
|
|
482
|
+
let existingPath = filePath;
|
|
483
|
+
while (!(await fs.exists(existingPath))) {
|
|
484
|
+
const parentPath = fs.dirname(existingPath);
|
|
485
|
+
if (!parentPath || parentPath === existingPath) {
|
|
486
|
+
return Promise.resolve(fs.resolvePath(filePath));
|
|
487
|
+
}
|
|
488
|
+
missingSegments.unshift(basename(existingPath));
|
|
489
|
+
existingPath = parentPath;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const resolvedExistingPath = await Promise.resolve(fs.resolvePath(existingPath));
|
|
493
|
+
return missingSegments.reduce((resolvedPath, segment) => fs.join(resolvedPath, segment), resolvedExistingPath);
|
|
458
494
|
}
|
|
459
495
|
|
|
460
|
-
async function
|
|
496
|
+
async function assertRestoreOutputDir(
|
|
461
497
|
inputDir: string,
|
|
462
498
|
outputDir: string,
|
|
463
|
-
outputProjectPath: string,
|
|
464
499
|
fs: RestoreFileSystem,
|
|
465
500
|
force: boolean,
|
|
466
|
-
outputIsProjectFile: boolean,
|
|
467
501
|
): Promise<void> {
|
|
468
502
|
const [resolvedInputDir, resolvedOutputDir] = await Promise.all([
|
|
469
|
-
|
|
470
|
-
|
|
503
|
+
resolvePathForContainment(inputDir, fs),
|
|
504
|
+
resolvePathForContainment(outputDir, fs),
|
|
471
505
|
]);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
try {
|
|
482
|
-
await fs.readdir(outputDir);
|
|
483
|
-
} catch {
|
|
484
|
-
throw new Error(`Restore output path is not a directory: ${outputDir}`);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (!(await fs.exists(outputProjectPath))) return;
|
|
488
|
-
if (!force) {
|
|
489
|
-
throw new Error(`Restore output file already exists: ${outputProjectPath}. Use --force to overwrite it.`);
|
|
490
|
-
}
|
|
491
|
-
if (!fs.rm) {
|
|
492
|
-
throw new Error('Restore output file already exists and the provided fs does not support rm(...).');
|
|
493
|
-
}
|
|
494
|
-
await fs.rm(outputProjectPath, { recursive: true, force: true });
|
|
495
|
-
return;
|
|
506
|
+
const normalizedInputDir = normalizeComparablePath(resolvedInputDir);
|
|
507
|
+
const normalizedOutputDir = normalizeComparablePath(resolvedOutputDir);
|
|
508
|
+
if (
|
|
509
|
+
normalizedInputDir === normalizedOutputDir ||
|
|
510
|
+
isPathWithin(normalizedInputDir, normalizedOutputDir) ||
|
|
511
|
+
isPathWithin(normalizedOutputDir, normalizedInputDir)
|
|
512
|
+
) {
|
|
513
|
+
throw new Error('Restore output directory must be independent from the published input directory.');
|
|
496
514
|
}
|
|
497
515
|
|
|
498
|
-
|
|
499
|
-
if (!exists) {
|
|
500
|
-
await fs.mkdir(outputDir);
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
516
|
+
if (!(await fs.exists(outputDir))) return;
|
|
503
517
|
|
|
504
518
|
let entries: string[];
|
|
505
519
|
try {
|
|
@@ -512,24 +526,69 @@ async function prepareRestoreOutputDir(
|
|
|
512
526
|
if (!force) {
|
|
513
527
|
throw new Error(`Restore output directory is not empty: ${outputDir}. Use --force to overwrite it.`);
|
|
514
528
|
}
|
|
515
|
-
|
|
516
|
-
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function createRestoreStagingDir(outputDir: string, fs: RestoreFileSystem): Promise<string> {
|
|
532
|
+
const parentDir = fs.dirname(outputDir) || '.';
|
|
533
|
+
await fs.mkdir(parentDir);
|
|
534
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
535
|
+
const stagingDir = fs.join(parentDir, `.${basename(outputDir)}.restore-${generateId()}`);
|
|
536
|
+
if (await fs.exists(stagingDir)) continue;
|
|
537
|
+
await fs.mkdir(stagingDir);
|
|
538
|
+
return stagingDir;
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`restore: Could not allocate a staging directory beside ${outputDir}.`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function commitRestoreOutput(
|
|
544
|
+
stagingDir: string,
|
|
545
|
+
outputDir: string,
|
|
546
|
+
fs: RestoreFileSystem,
|
|
547
|
+
): Promise<string | null> {
|
|
548
|
+
if (!(await fs.exists(outputDir))) {
|
|
549
|
+
await fs.rename(stagingDir, outputDir);
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const parentDir = fs.dirname(outputDir) || '.';
|
|
554
|
+
let backupDir = '';
|
|
555
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
556
|
+
const candidate = fs.join(parentDir, `.${basename(outputDir)}.restore-backup-${generateId()}`);
|
|
557
|
+
if (!(await fs.exists(candidate))) {
|
|
558
|
+
backupDir = candidate;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!backupDir) throw new Error(`restore: Could not allocate a backup directory beside ${outputDir}.`);
|
|
563
|
+
|
|
564
|
+
// ponytail: two-step rename preserves rollback; use a platform directory-exchange primitive if zero reader gap matters.
|
|
565
|
+
await fs.rename(outputDir, backupDir);
|
|
566
|
+
try {
|
|
567
|
+
await fs.rename(stagingDir, outputDir);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
await fs.rename(backupDir, outputDir);
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
574
|
+
return null;
|
|
575
|
+
} catch {
|
|
576
|
+
return `restore: Previous output retained at ${backupDir}; remove it after checking the restored project.`;
|
|
517
577
|
}
|
|
518
|
-
await fs.rm(outputDir, { recursive: true, force: true });
|
|
519
|
-
await fs.mkdir(outputDir);
|
|
520
578
|
}
|
|
521
579
|
|
|
522
580
|
export async function restore(options: RestoreOptions): Promise<RestoreResult> {
|
|
523
581
|
const sourceDir = trimTrailingSlashes(options.inputDir);
|
|
524
|
-
const
|
|
525
|
-
const outputProjectPath = resolveOutputProjectPath(
|
|
526
|
-
|
|
527
|
-
await prepareRestoreOutputDir(sourceDir, outputDir, outputProjectPath, options.fs, options.force === true, outputIsProjectFile);
|
|
582
|
+
const outputDir = normalizeRestoreOutputDir(options.output);
|
|
583
|
+
const outputProjectPath = resolveOutputProjectPath(outputDir, options.fs);
|
|
584
|
+
await assertRestoreOutputDir(sourceDir, outputDir, options.fs, options.force === true);
|
|
528
585
|
|
|
529
586
|
const packageFilter = options.packages?.length ? new Set(options.packages) : null;
|
|
530
|
-
const
|
|
587
|
+
const binaryNames = (await options.fs.readdir(sourceDir))
|
|
531
588
|
.filter((name) => isPublishedBinaryFile(name))
|
|
532
|
-
.filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)))
|
|
589
|
+
.filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)));
|
|
590
|
+
for (const binaryName of binaryNames) assertSafeRestoreSegment(binaryName, 'published binary file name');
|
|
591
|
+
const candidateBinaryPaths = binaryNames
|
|
533
592
|
.map((name) => options.fs.join(sourceDir, name))
|
|
534
593
|
.sort((left, right) => left.localeCompare(right));
|
|
535
594
|
const binaryPaths = (await Promise.all(
|
|
@@ -543,7 +602,7 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
|
|
|
543
602
|
}
|
|
544
603
|
|
|
545
604
|
const restorer = new RestoreWorkflow(options.fs);
|
|
546
|
-
|
|
605
|
+
const document = await restorer.prepare({
|
|
547
606
|
binaryPaths,
|
|
548
607
|
sourceDir,
|
|
549
608
|
outputProjectPath,
|
|
@@ -551,6 +610,26 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
|
|
|
551
610
|
cropImage: options.cropImage,
|
|
552
611
|
extractImage: options.extractImage,
|
|
553
612
|
});
|
|
613
|
+
const stagingDir = await createRestoreStagingDir(outputDir, options.fs);
|
|
614
|
+
const stagingProjectPath = options.fs.join(stagingDir, basename(outputProjectPath));
|
|
615
|
+
const warnings: string[] = [];
|
|
616
|
+
try {
|
|
617
|
+
await restorer.write(document, {
|
|
618
|
+
binaryPaths,
|
|
619
|
+
sourceDir,
|
|
620
|
+
outputProjectPath: stagingProjectPath,
|
|
621
|
+
projectType: options.projectType,
|
|
622
|
+
cropImage: options.cropImage,
|
|
623
|
+
extractImage: options.extractImage,
|
|
624
|
+
}, warnings);
|
|
625
|
+
const cleanupWarning = await commitRestoreOutput(stagingDir, outputDir, options.fs);
|
|
626
|
+
if (cleanupWarning) warnings.push(cleanupWarning);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
await options.fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return { document, projectPath: outputProjectPath, warnings };
|
|
554
633
|
}
|
|
555
634
|
|
|
556
635
|
class RestoreWorkflow {
|
|
@@ -560,13 +639,14 @@ class RestoreWorkflow {
|
|
|
560
639
|
this._fs = fs;
|
|
561
640
|
}
|
|
562
641
|
|
|
563
|
-
async
|
|
564
|
-
const warnings: string[] = [];
|
|
642
|
+
async prepare(options: RestoreExecutionOptions): Promise<Document> {
|
|
565
643
|
const reader = new BinaryReader(this._fs);
|
|
566
644
|
const doc = await reader.readMany(options.binaryPaths);
|
|
645
|
+
this._assertDocumentPaths(doc);
|
|
567
646
|
this._initializeProjectDefaults(doc, options.projectType);
|
|
568
647
|
this._initializeImageFileNames(doc);
|
|
569
648
|
this._initializeLooseResourceFileNames(doc);
|
|
649
|
+
this._assertDocumentPaths(doc);
|
|
570
650
|
await this._synthesizeLooseSkeletonResources(doc, options.sourceDir);
|
|
571
651
|
this._initializeRestoredResourceRelations(doc);
|
|
572
652
|
this._initializePublishedFontTextureIds(doc);
|
|
@@ -575,16 +655,30 @@ class RestoreWorkflow {
|
|
|
575
655
|
this._initializePublishedTextFontResources(doc);
|
|
576
656
|
this._initializeDisplayObjectFileNames(doc);
|
|
577
657
|
this._initializePublishedFontDefaults(doc);
|
|
658
|
+
this._assertDocumentPaths(doc);
|
|
659
|
+
return doc;
|
|
660
|
+
}
|
|
578
661
|
|
|
662
|
+
async write(doc: Document, options: RestoreExecutionOptions, warnings: string[]): Promise<void> {
|
|
579
663
|
const writer = new ProjectWriter(this._fs);
|
|
580
664
|
await writer.write(doc, options.outputProjectPath);
|
|
581
665
|
await this._restoreAssets(doc, options, warnings);
|
|
666
|
+
}
|
|
582
667
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
668
|
+
private _assertDocumentPaths(doc: Document): void {
|
|
669
|
+
for (const pkg of doc.getRoot().listPackages()) {
|
|
670
|
+
assertSafeRestoreSegment(pkg.getName(), 'package name');
|
|
671
|
+
assertSafeRestoreSegment(pkg.getPublishName() || pkg.getName(), 'package publish name');
|
|
672
|
+
for (const resource of pkg.listResources() as RestorableResource[]) {
|
|
673
|
+
normalizeVirtualPath(resource.getPath?.());
|
|
674
|
+
const branch = resource.getBranch?.() ?? '';
|
|
675
|
+
if (branch) assertSafeRestoreSegment(branch, 'branch name');
|
|
676
|
+
const fileName = resourceFileName(resource);
|
|
677
|
+
if (fileName) assertSafeRestoreSegment(fileName, 'resource file name');
|
|
678
|
+
const publishedFileName = resourcePublishedFileName(resource);
|
|
679
|
+
if (publishedFileName) assertSafeRestoreSegment(publishedFileName, 'published resource file name');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
588
682
|
}
|
|
589
683
|
|
|
590
684
|
private _initializeProjectDefaults(doc: Document, projectType?: number): void {
|
|
@@ -1237,12 +1331,17 @@ class RestoreWorkflow {
|
|
|
1237
1331
|
|
|
1238
1332
|
private _sourceFileCandidates(pkg: Package, fileName: string, outputFileName = fileName): string[] {
|
|
1239
1333
|
const publishName = pkg.getPublishName() || pkg.getName();
|
|
1240
|
-
|
|
1334
|
+
assertSafeRestoreSegment(publishName, 'package publish name');
|
|
1335
|
+
assertSafeRestoreSegment(fileName, 'published source file name');
|
|
1336
|
+
assertSafeRestoreSegment(outputFileName, 'published source file name');
|
|
1337
|
+
const candidates = Array.from(new Set([
|
|
1241
1338
|
`${publishName}_${fileName}`,
|
|
1242
1339
|
fileName,
|
|
1243
1340
|
`${publishName}_${outputFileName}`,
|
|
1244
1341
|
outputFileName,
|
|
1245
1342
|
]));
|
|
1343
|
+
for (const candidate of candidates) assertSafeRestoreSegment(candidate, 'published source file name');
|
|
1344
|
+
return candidates;
|
|
1246
1345
|
}
|
|
1247
1346
|
|
|
1248
1347
|
private async _resolveLooseSourceFile(pkg: Package, sourceDir: string, outputFileName: string): Promise<string | null> {
|
|
@@ -1255,9 +1354,16 @@ class RestoreWorkflow {
|
|
|
1255
1354
|
}
|
|
1256
1355
|
|
|
1257
1356
|
private async _resolveSourceFile(sourceDir: string, candidates: string[]): Promise<string | null> {
|
|
1357
|
+
const resolvedSourceDir = await Promise.resolve(this._fs.resolvePath(sourceDir));
|
|
1258
1358
|
for (const candidate of candidates) {
|
|
1359
|
+
assertSafeRestoreSegment(candidate, 'published source file name');
|
|
1259
1360
|
const sourcePath = this._fs.join(sourceDir, candidate);
|
|
1260
|
-
if (await this._fs.isFile(sourcePath))
|
|
1361
|
+
if (!(await this._fs.isFile(sourcePath))) continue;
|
|
1362
|
+
const resolvedSourcePath = await Promise.resolve(this._fs.resolvePath(sourcePath));
|
|
1363
|
+
if (!isPathWithin(resolvedSourceDir, resolvedSourcePath)) {
|
|
1364
|
+
throw new Error(`restore: Published source file resolves outside the input directory: ${candidate}.`);
|
|
1365
|
+
}
|
|
1366
|
+
return resolvedSourcePath;
|
|
1261
1367
|
}
|
|
1262
1368
|
return null;
|
|
1263
1369
|
}
|
|
@@ -1270,6 +1376,9 @@ class RestoreWorkflow {
|
|
|
1270
1376
|
): string {
|
|
1271
1377
|
const basePath = this._fs.dirname(outputProjectPath);
|
|
1272
1378
|
const branch = resource.getBranch?.() ?? '';
|
|
1379
|
+
assertSafeRestoreSegment(pkg.getName(), 'package name');
|
|
1380
|
+
if (branch) assertSafeRestoreSegment(branch, 'branch name');
|
|
1381
|
+
assertSafeRestoreSegment(fileName, 'resource file name');
|
|
1273
1382
|
const assetsDir = branch ? `assets_${branch}` : 'assets';
|
|
1274
1383
|
const virtualPath = normalizeVirtualPath(resource.getPath?.());
|
|
1275
1384
|
const pkgDir = this._fs.join(basePath, assetsDir, pkg.getName());
|