@series-inc/stowkit-cli 0.6.17 → 0.6.19
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/dist/app/disk-project.d.ts +24 -1
- package/dist/app/disk-project.js +7 -0
- package/dist/app/process-cache.js +1 -1
- package/dist/app/state.d.ts +2 -1
- package/dist/app/state.js +2 -1
- package/dist/app/stowmeta-io.d.ts +6 -3
- package/dist/app/stowmeta-io.js +62 -21
- package/dist/app/thumbnail-cache.d.ts +29 -0
- package/dist/app/thumbnail-cache.js +137 -0
- package/dist/assets-package.d.ts +66 -0
- package/dist/assets-package.js +80 -0
- package/dist/cleanup.js +11 -2
- package/dist/cli.js +47 -0
- package/dist/core/constants.d.ts +4 -2
- package/dist/core/constants.js +4 -2
- package/dist/core/types.d.ts +5 -0
- package/dist/core/types.js +5 -0
- package/dist/encoders/basis-encoder.js +2 -1
- package/dist/format/metadata.js +12 -7
- package/dist/gcs.d.ts +10 -0
- package/dist/gcs.js +158 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/node-fs.d.ts +4 -0
- package/dist/node-fs.js +14 -0
- package/dist/orchestrator.js +37 -10
- package/dist/pipeline.js +1 -0
- package/dist/publish.d.ts +27 -0
- package/dist/publish.js +418 -0
- package/dist/server.js +567 -20
- package/dist/store.d.ts +50 -0
- package/dist/store.js +305 -0
- package/package.json +3 -3
- package/skill.md +63 -0
package/dist/publish.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { readProjectConfig, scanDirectory } from './node-fs.js';
|
|
4
|
+
import { readAssetsPackage, initAssetsPackage, createEmptyRegistry, } from './assets-package.js';
|
|
5
|
+
import { createGCSClient } from './gcs.js';
|
|
6
|
+
import { readStowmeta } from './app/stowmeta-io.js';
|
|
7
|
+
// ─── Build full dependency graph from srcArtDir ──────────────────────────────
|
|
8
|
+
async function scanAssets(srcArtDir, scan, verbose) {
|
|
9
|
+
const assets = [];
|
|
10
|
+
// Index source files by relative path for size lookup
|
|
11
|
+
const sourceByPath = new Map();
|
|
12
|
+
for (const f of scan.sourceFiles)
|
|
13
|
+
sourceByPath.set(f.relativePath, f);
|
|
14
|
+
// Pass 1: read all stowmeta files, collect assets with raw file-path deps
|
|
15
|
+
for (const metaFile of scan.metaFiles) {
|
|
16
|
+
const assetPath = metaFile.relativePath.replace(/\.stowmeta$/, '');
|
|
17
|
+
const meta = await readStowmeta(srcArtDir, assetPath);
|
|
18
|
+
if (!meta)
|
|
19
|
+
continue;
|
|
20
|
+
if (meta.type === 'glbContainer') {
|
|
21
|
+
const container = meta;
|
|
22
|
+
for (const child of container.children) {
|
|
23
|
+
if (child.excluded)
|
|
24
|
+
continue;
|
|
25
|
+
const childFiles = [assetPath, metaFile.relativePath];
|
|
26
|
+
const rawDeps = [];
|
|
27
|
+
if (child.childType === 'staticMesh' || child.childType === 'skinnedMesh') {
|
|
28
|
+
if (child.materialOverrides) {
|
|
29
|
+
for (const ref of Object.values(child.materialOverrides)) {
|
|
30
|
+
if (ref)
|
|
31
|
+
rawDeps.push(ref);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (child.childType === 'materialSchema' && child.materialConfig) {
|
|
36
|
+
for (const prop of child.materialConfig.properties) {
|
|
37
|
+
if (prop.textureAsset)
|
|
38
|
+
rawDeps.push(prop.textureAsset);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (child.childType === 'animationClip' && child.targetMeshId) {
|
|
42
|
+
rawDeps.push(child.targetMeshId);
|
|
43
|
+
}
|
|
44
|
+
assets.push({
|
|
45
|
+
stringId: child.stringId,
|
|
46
|
+
type: child.childType,
|
|
47
|
+
sourceFile: assetPath,
|
|
48
|
+
files: childFiles,
|
|
49
|
+
size: sourceByPath.get(assetPath)?.size ?? 0,
|
|
50
|
+
tags: child.tags ?? [],
|
|
51
|
+
dependencies: [], // resolved in pass 2
|
|
52
|
+
rawDeps,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (meta.excluded)
|
|
58
|
+
continue;
|
|
59
|
+
const files = [assetPath, metaFile.relativePath];
|
|
60
|
+
const rawDeps = [];
|
|
61
|
+
// Mesh → material refs (file paths like "Materials/M_Sea_Floor.stowmat")
|
|
62
|
+
if (meta.type === 'staticMesh' || meta.type === 'skinnedMesh') {
|
|
63
|
+
for (const ref of Object.values(meta.materialOverrides)) {
|
|
64
|
+
if (ref)
|
|
65
|
+
rawDeps.push(ref);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Animation → target mesh (stringId or file path)
|
|
69
|
+
if (meta.type === 'animationClip' && meta.targetMeshId) {
|
|
70
|
+
rawDeps.push(meta.targetMeshId);
|
|
71
|
+
}
|
|
72
|
+
// Material schema (.stowmat is the source file itself) → texture refs
|
|
73
|
+
// The source file IS the .stowmat, and it's already a source file in the scan.
|
|
74
|
+
// We need to read it to find texture dependencies.
|
|
75
|
+
if (meta.type === 'materialSchema' && assetPath.endsWith('.stowmat')) {
|
|
76
|
+
try {
|
|
77
|
+
const matText = await fs.readFile(path.join(srcArtDir, assetPath), 'utf-8');
|
|
78
|
+
const mat = JSON.parse(matText);
|
|
79
|
+
if (mat.properties) {
|
|
80
|
+
for (const prop of mat.properties) {
|
|
81
|
+
if (prop.textureAsset)
|
|
82
|
+
rawDeps.push(prop.textureAsset);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch { /* not a valid stowmat */ }
|
|
87
|
+
}
|
|
88
|
+
assets.push({
|
|
89
|
+
stringId: meta.stringId,
|
|
90
|
+
type: meta.type,
|
|
91
|
+
sourceFile: assetPath,
|
|
92
|
+
files,
|
|
93
|
+
size: sourceByPath.get(assetPath)?.size ?? 0,
|
|
94
|
+
tags: meta.tags ?? [],
|
|
95
|
+
dependencies: [], // resolved in pass 2
|
|
96
|
+
rawDeps,
|
|
97
|
+
...(meta.type === 'texture' && meta.filtering ? { filtering: meta.filtering } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Pass 2: resolve raw file-path deps → stringIds
|
|
101
|
+
// Build lookup: source file path → stringId
|
|
102
|
+
const pathToStringId = new Map();
|
|
103
|
+
for (const asset of assets) {
|
|
104
|
+
pathToStringId.set(asset.sourceFile, asset.stringId);
|
|
105
|
+
}
|
|
106
|
+
for (const asset of assets) {
|
|
107
|
+
for (const ref of asset.rawDeps) {
|
|
108
|
+
// Try as file path first
|
|
109
|
+
const resolved = pathToStringId.get(ref);
|
|
110
|
+
if (resolved) {
|
|
111
|
+
asset.dependencies.push(resolved);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Maybe it's already a stringId
|
|
115
|
+
const byStringId = assets.find(a => a.stringId === ref);
|
|
116
|
+
if (byStringId) {
|
|
117
|
+
asset.dependencies.push(ref);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Unresolved — keep the raw ref so warning still fires
|
|
121
|
+
asset.dependencies.push(ref);
|
|
122
|
+
}
|
|
123
|
+
if (asset.dependencies.length > 0) {
|
|
124
|
+
verbose(`${asset.stringId} [${asset.type}] → ${asset.dependencies.join(', ')}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return assets;
|
|
128
|
+
}
|
|
129
|
+
// ─── Main Publish Function ───────────────────────────────────────────────────
|
|
130
|
+
export async function publishPackage(projectDir, opts = {}) {
|
|
131
|
+
const log = (msg) => console.log(msg);
|
|
132
|
+
const vlog = (msg) => { if (opts.verbose)
|
|
133
|
+
console.log(` ${msg}`); };
|
|
134
|
+
// Step 1: Read project config
|
|
135
|
+
const project = await readProjectConfig(projectDir);
|
|
136
|
+
log(`Project: ${project.projectName}`);
|
|
137
|
+
// Step 2: Read or auto-create assets-package.json
|
|
138
|
+
let pkg = await readAssetsPackage(projectDir);
|
|
139
|
+
if (!pkg) {
|
|
140
|
+
log('No assets-package.json found — creating default...');
|
|
141
|
+
pkg = await initAssetsPackage(projectDir, { name: project.projectName });
|
|
142
|
+
log(`Created assets-package.json (name: ${pkg.name}, version: ${pkg.version})`);
|
|
143
|
+
}
|
|
144
|
+
// Step 3: Resolve bucket
|
|
145
|
+
const bucketUri = opts.bucket
|
|
146
|
+
?? pkg.bucket
|
|
147
|
+
?? process.env.STOWKIT_BUCKET
|
|
148
|
+
?? 'gs://venus-shared-assets-test';
|
|
149
|
+
log(`Bucket: ${bucketUri}`);
|
|
150
|
+
// Step 4: Scan srcArtDir
|
|
151
|
+
const scan = await scanDirectory(project.srcArtDir);
|
|
152
|
+
if (scan.sourceFiles.length === 0) {
|
|
153
|
+
throw new Error(`No source assets found in ${project.srcArtDir}. Nothing to publish.`);
|
|
154
|
+
}
|
|
155
|
+
// Step 5: Build asset list with full dependency graph
|
|
156
|
+
const scannedAssets = await scanAssets(project.srcArtDir, scan, vlog);
|
|
157
|
+
log(`Assets: ${scannedAssets.length}`);
|
|
158
|
+
// Validate: reject duplicate stringIds
|
|
159
|
+
const idCounts = new Map();
|
|
160
|
+
for (const asset of scannedAssets) {
|
|
161
|
+
const files = idCounts.get(asset.stringId) ?? [];
|
|
162
|
+
files.push(asset.sourceFile);
|
|
163
|
+
idCounts.set(asset.stringId, files);
|
|
164
|
+
}
|
|
165
|
+
const duplicates = [...idCounts.entries()].filter(([, files]) => files.length > 1);
|
|
166
|
+
if (duplicates.length > 0) {
|
|
167
|
+
const lines = duplicates.map(([id, files]) => ` "${id}" used by:\n${files.map(f => ` - ${f}`).join('\n')}`);
|
|
168
|
+
throw new Error(`Cannot publish: ${duplicates.length} duplicate stringId(s) found. Each asset must have a unique stringId.\n${lines.join('\n')}`);
|
|
169
|
+
}
|
|
170
|
+
// Validate: warn about unresolved dependencies
|
|
171
|
+
const knownIds = new Set(scannedAssets.map(a => a.stringId));
|
|
172
|
+
for (const asset of scannedAssets) {
|
|
173
|
+
for (const dep of asset.dependencies) {
|
|
174
|
+
if (!knownIds.has(dep)) {
|
|
175
|
+
console.warn(` Warning: ${asset.stringId} depends on "${dep}" which was not found`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Collect all unique files to upload
|
|
180
|
+
const allFiles = new Set();
|
|
181
|
+
for (const asset of scannedAssets) {
|
|
182
|
+
for (const f of asset.files)
|
|
183
|
+
allFiles.add(f);
|
|
184
|
+
}
|
|
185
|
+
for (const f of scan.sourceFiles)
|
|
186
|
+
allFiles.add(f.relativePath);
|
|
187
|
+
for (const f of scan.metaFiles)
|
|
188
|
+
allFiles.add(f.relativePath);
|
|
189
|
+
for (const f of scan.matFiles)
|
|
190
|
+
allFiles.add(f.relativePath);
|
|
191
|
+
const filesToUpload = [...allFiles].sort();
|
|
192
|
+
let totalSize = 0;
|
|
193
|
+
for (const rel of filesToUpload) {
|
|
194
|
+
const stat = await fs.stat(path.join(project.srcArtDir, rel)).catch(() => null);
|
|
195
|
+
totalSize += stat?.size ?? 0;
|
|
196
|
+
}
|
|
197
|
+
log(`Files to upload: ${filesToUpload.length} (${formatBytes(totalSize)})`);
|
|
198
|
+
log(` ${scan.sourceFiles.length} source, ${scan.metaFiles.length} stowmeta, ${scan.matFiles.length} stowmat`);
|
|
199
|
+
// Step 6: Build registry assets
|
|
200
|
+
const thumbMap = opts.thumbnails ?? {};
|
|
201
|
+
const registryAssets = scannedAssets.map(a => {
|
|
202
|
+
const thumb = thumbMap[a.stringId];
|
|
203
|
+
return {
|
|
204
|
+
stringId: a.stringId,
|
|
205
|
+
type: a.type,
|
|
206
|
+
file: a.sourceFile,
|
|
207
|
+
files: a.files,
|
|
208
|
+
size: a.size,
|
|
209
|
+
tags: a.tags,
|
|
210
|
+
dependencies: a.dependencies,
|
|
211
|
+
...(thumb ? { thumbnail: true, thumbnailFormat: thumb.format } : {}),
|
|
212
|
+
...(a.filtering && a.filtering !== 'linear' ? { filtering: a.filtering } : {}),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
const versionEntry = {
|
|
216
|
+
publishedAt: new Date().toISOString(),
|
|
217
|
+
fileCount: filesToUpload.length,
|
|
218
|
+
totalSize,
|
|
219
|
+
assets: registryAssets,
|
|
220
|
+
};
|
|
221
|
+
// Dry run — print summary and exit
|
|
222
|
+
if (opts.dryRun) {
|
|
223
|
+
log('\n--- DRY RUN ---');
|
|
224
|
+
log(`Would publish ${pkg.name}@${pkg.version} to ${bucketUri}`);
|
|
225
|
+
log(`Would upload ${filesToUpload.length} files (${formatBytes(totalSize)})`);
|
|
226
|
+
log(`\nAssets (${registryAssets.length}):`);
|
|
227
|
+
for (const a of registryAssets) {
|
|
228
|
+
const deps = a.dependencies.length > 0 ? ` → [${a.dependencies.join(', ')}]` : '';
|
|
229
|
+
const tags = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
|
|
230
|
+
log(` [${a.type}] ${a.stringId} (${a.file}, ${formatBytes(a.size)})${tags}${deps}`);
|
|
231
|
+
}
|
|
232
|
+
// Demo transitive resolution
|
|
233
|
+
const { resolveTransitiveDeps } = await import('./assets-package.js');
|
|
234
|
+
const example = registryAssets.find(a => a.dependencies.length > 0);
|
|
235
|
+
if (example) {
|
|
236
|
+
log(`\nExample: pulling "${example.stringId}" would also grab:`);
|
|
237
|
+
const resolved = resolveTransitiveDeps([example.stringId], registryAssets);
|
|
238
|
+
for (const id of resolved) {
|
|
239
|
+
const asset = registryAssets.find(a => a.stringId === id);
|
|
240
|
+
log(` ${id === example.stringId ? '→' : ' +'} ${id} [${asset?.type ?? '?'}]`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
log('\nNo files were uploaded.');
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
packageName: pkg.name,
|
|
247
|
+
version: pkg.version,
|
|
248
|
+
assetCount: registryAssets.length,
|
|
249
|
+
fileCount: filesToUpload.length,
|
|
250
|
+
thumbnailCount: Object.keys(thumbMap).length,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// Step 7: Create GCS client and auth
|
|
254
|
+
const emitProgress = opts.onProgress ?? (() => { });
|
|
255
|
+
const totalUploads = filesToUpload.length + Object.keys(thumbMap).length + 2; // +2 for assets-package.json and registry.json
|
|
256
|
+
let totalDone = 0;
|
|
257
|
+
log('Authenticating with GCS...');
|
|
258
|
+
emitProgress({ phase: 'auth', done: 0, total: totalUploads, message: 'Authenticating with GCS...' });
|
|
259
|
+
const gcs = await createGCSClient(projectDir, bucketUri);
|
|
260
|
+
// Step 8: Download current registry
|
|
261
|
+
log('Fetching registry...');
|
|
262
|
+
const registryResult = await gcs.downloadWithGeneration('registry.json');
|
|
263
|
+
let registry;
|
|
264
|
+
let generation;
|
|
265
|
+
if (registryResult) {
|
|
266
|
+
registry = JSON.parse(registryResult.data);
|
|
267
|
+
generation = registryResult.generation;
|
|
268
|
+
vlog(`Registry loaded (generation: ${generation})`);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
registry = createEmptyRegistry();
|
|
272
|
+
generation = null;
|
|
273
|
+
vlog('No existing registry — will create new one');
|
|
274
|
+
}
|
|
275
|
+
// Step 9: Check version not already published
|
|
276
|
+
const existingPkg = registry.packages[pkg.name];
|
|
277
|
+
if (existingPkg?.versions[pkg.version] && !opts.force) {
|
|
278
|
+
throw new Error(`Version ${pkg.version} of "${pkg.name}" is already published. ` +
|
|
279
|
+
`Bump the version in assets-package.json or use --force to overwrite.`);
|
|
280
|
+
}
|
|
281
|
+
// Step 10: Upload all files (concurrent)
|
|
282
|
+
const uploadPrefix = `packages/${pkg.name}/${pkg.version}`;
|
|
283
|
+
let uploaded = 0;
|
|
284
|
+
const UPLOAD_CONCURRENCY = 10;
|
|
285
|
+
emitProgress({ phase: 'files', done: 0, total: totalUploads, message: `Uploading ${filesToUpload.length} files...` });
|
|
286
|
+
await runConcurrent(filesToUpload, UPLOAD_CONCURRENCY, async (relativePath) => {
|
|
287
|
+
const objectPath = `${uploadPrefix}/${relativePath}`;
|
|
288
|
+
const absPath = path.join(project.srcArtDir, relativePath);
|
|
289
|
+
const data = new Uint8Array(await fs.readFile(absPath));
|
|
290
|
+
await gcs.upload(objectPath, data, guessContentType(relativePath));
|
|
291
|
+
uploaded++;
|
|
292
|
+
totalDone++;
|
|
293
|
+
emitProgress({ phase: 'files', done: totalDone, total: totalUploads, message: `Uploading files: ${uploaded}/${filesToUpload.length}` });
|
|
294
|
+
if (opts.verbose) {
|
|
295
|
+
vlog(`[${uploaded}/${filesToUpload.length}] ${relativePath} (${formatBytes(data.length)})`);
|
|
296
|
+
}
|
|
297
|
+
else if (uploaded % 10 === 0 || uploaded === filesToUpload.length) {
|
|
298
|
+
log(` Uploaded ${uploaded}/${filesToUpload.length} files...`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// Step 11: Upload thumbnails concurrently (if provided by packer GUI)
|
|
302
|
+
let thumbnailCount = 0;
|
|
303
|
+
const thumbEntries = opts.thumbnails ? Object.entries(opts.thumbnails) : [];
|
|
304
|
+
if (thumbEntries.length > 0) {
|
|
305
|
+
emitProgress({ phase: 'thumbnails', done: totalDone, total: totalUploads, message: `Uploading thumbnails...` });
|
|
306
|
+
}
|
|
307
|
+
await runConcurrent(thumbEntries, UPLOAD_CONCURRENCY, async ([stringId, thumb]) => {
|
|
308
|
+
const binaryData = Buffer.from(thumb.data, 'base64');
|
|
309
|
+
const ext = thumb.format === 'webm' ? 'webm' : thumb.format === 'webp' ? 'webp' : 'png';
|
|
310
|
+
const mime = thumb.format === 'webm' ? 'video/webm' : thumb.format === 'webp' ? 'image/webp' : 'image/png';
|
|
311
|
+
const objectPath = `${uploadPrefix}/thumbnails/${stringId}.${ext}`;
|
|
312
|
+
await gcs.upload(objectPath, new Uint8Array(binaryData), mime);
|
|
313
|
+
thumbnailCount++;
|
|
314
|
+
totalDone++;
|
|
315
|
+
emitProgress({ phase: 'thumbnails', done: totalDone, total: totalUploads, message: `Uploading thumbnails: ${thumbnailCount}/${thumbEntries.length}` });
|
|
316
|
+
});
|
|
317
|
+
if (thumbnailCount > 0)
|
|
318
|
+
log(` Uploaded ${thumbnailCount} thumbnails`);
|
|
319
|
+
// Step 12: Upload assets-package.json receipt copy
|
|
320
|
+
emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Uploading package manifest...' });
|
|
321
|
+
const receiptPath = `${uploadPrefix}/assets-package.json`;
|
|
322
|
+
await gcs.upload(receiptPath, JSON.stringify(pkg, null, 2), 'application/json');
|
|
323
|
+
totalDone++;
|
|
324
|
+
// Step 12b: Upload pack-level thumbnail if present in project root
|
|
325
|
+
let packThumbnail;
|
|
326
|
+
for (const candidate of ['thumbnail.webp', 'thumbnail.png', 'thumbnail.jpg']) {
|
|
327
|
+
const thumbPath = path.join(projectDir, candidate);
|
|
328
|
+
try {
|
|
329
|
+
const thumbData = new Uint8Array(await fs.readFile(thumbPath));
|
|
330
|
+
const ext = candidate.split('.').pop();
|
|
331
|
+
const mime = ext === 'webp' ? 'image/webp' : ext === 'png' ? 'image/png' : 'image/jpeg';
|
|
332
|
+
const objectPath = `${uploadPrefix}/${candidate}`;
|
|
333
|
+
await gcs.upload(objectPath, thumbData, mime);
|
|
334
|
+
packThumbnail = candidate;
|
|
335
|
+
totalDone++;
|
|
336
|
+
log(` Uploaded pack thumbnail: ${candidate} (${formatBytes(thumbData.length)})`);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
catch { /* file doesn't exist, try next */ }
|
|
340
|
+
}
|
|
341
|
+
// Step 13: Update and upload registry
|
|
342
|
+
log('Updating registry...');
|
|
343
|
+
if (!registry.packages[pkg.name]) {
|
|
344
|
+
registry.packages[pkg.name] = {
|
|
345
|
+
description: pkg.description,
|
|
346
|
+
author: pkg.author,
|
|
347
|
+
tags: pkg.tags ?? [],
|
|
348
|
+
latest: pkg.version,
|
|
349
|
+
versions: {},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const regPkg = registry.packages[pkg.name];
|
|
353
|
+
regPkg.versions[pkg.version] = versionEntry;
|
|
354
|
+
regPkg.latest = pkg.version;
|
|
355
|
+
regPkg.description = pkg.description;
|
|
356
|
+
regPkg.author = pkg.author;
|
|
357
|
+
regPkg.tags = pkg.tags ?? [];
|
|
358
|
+
if (packThumbnail)
|
|
359
|
+
regPkg.thumbnail = packThumbnail;
|
|
360
|
+
emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Updating registry...' });
|
|
361
|
+
await gcs.uploadWithGeneration('registry.json', JSON.stringify(registry, null, 2), generation);
|
|
362
|
+
totalDone++;
|
|
363
|
+
emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Done' });
|
|
364
|
+
log(`\nPublished ${pkg.name}@${pkg.version} successfully!`);
|
|
365
|
+
log(` ${filesToUpload.length} files, ${registryAssets.length} assets, ${formatBytes(totalSize)}`);
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
packageName: pkg.name,
|
|
369
|
+
version: pkg.version,
|
|
370
|
+
assetCount: registryAssets.length,
|
|
371
|
+
fileCount: filesToUpload.length,
|
|
372
|
+
thumbnailCount,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
376
|
+
function formatBytes(bytes) {
|
|
377
|
+
if (bytes < 1024)
|
|
378
|
+
return `${bytes} B`;
|
|
379
|
+
if (bytes < 1024 * 1024)
|
|
380
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
381
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
382
|
+
}
|
|
383
|
+
const CONTENT_TYPES = {
|
|
384
|
+
png: 'image/png',
|
|
385
|
+
jpg: 'image/jpeg',
|
|
386
|
+
jpeg: 'image/jpeg',
|
|
387
|
+
webp: 'image/webp',
|
|
388
|
+
gif: 'image/gif',
|
|
389
|
+
bmp: 'image/bmp',
|
|
390
|
+
wav: 'audio/wav',
|
|
391
|
+
mp3: 'audio/mpeg',
|
|
392
|
+
ogg: 'audio/ogg',
|
|
393
|
+
flac: 'audio/flac',
|
|
394
|
+
aac: 'audio/aac',
|
|
395
|
+
m4a: 'audio/mp4',
|
|
396
|
+
fbx: 'application/octet-stream',
|
|
397
|
+
obj: 'text/plain',
|
|
398
|
+
glb: 'model/gltf-binary',
|
|
399
|
+
gltf: 'model/gltf+json',
|
|
400
|
+
json: 'application/json',
|
|
401
|
+
stowmeta: 'application/json',
|
|
402
|
+
stowmat: 'application/json',
|
|
403
|
+
};
|
|
404
|
+
function guessContentType(filePath) {
|
|
405
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
406
|
+
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
407
|
+
}
|
|
408
|
+
/** Run async tasks with a concurrency limit. */
|
|
409
|
+
async function runConcurrent(items, limit, fn) {
|
|
410
|
+
let i = 0;
|
|
411
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
412
|
+
while (i < items.length) {
|
|
413
|
+
const idx = i++;
|
|
414
|
+
await fn(items[idx]);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
await Promise.all(workers);
|
|
418
|
+
}
|