sandstone-cli 2.2.1 → 2.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.
@@ -1,869 +0,0 @@
1
- import path from 'node:path'
2
- import os from 'node:os'
3
- import crypto from 'node:crypto'
4
- import { pathToFileURL } from 'node:url'
5
- import fs from 'fs-extra'
6
- import chalk from 'chalk'
7
- import AdmZip from 'adm-zip'
8
-
9
- import type { BuildResult, ResourceCounts } from '../ui/types.js'
10
- import { log, logInfo, logWarn, logError as logErrorFn, logDebug, logTrace, initLoggerNoFile, setSilent } from '../ui/logger.js'
11
- import { canUseSymlinks } from '../utils.js'
12
- import { split } from 'obliterator'
13
-
14
- import type * as sandstone from 'sandstone'
15
- import type { handlerReadFile } from 'sandstone/pack'
16
-
17
- type SandstoneContext = ReturnType<typeof sandstone['getSandstoneContext']>
18
-
19
- declare global {
20
- interface RegExpConstructor {
21
- escape(str: string): string;
22
- }
23
- }
24
-
25
- // Console capture for watch mode - wraps console to redirect output to our log file
26
- const originalConsole = globalThis.console
27
- let consoleWrapped = false
28
-
29
- export function enableConsoleCapture() {
30
- if (consoleWrapped) return
31
- consoleWrapped = true
32
-
33
- // Wrap console methods to redirect to our logger with appropriate levels
34
- ;(globalThis.console as any).log = (...args: any[]) => log(...args)
35
- ;(globalThis.console as any).info = (...args: any[]) => logInfo(...args)
36
- ;(globalThis.console as any).warn = (...args: any[]) => logWarn(...args)
37
- ;(globalThis.console as any).error = (...args: any[]) => logErrorFn(args.join(' '))
38
- ;(globalThis.console as any).debug = (...args: any[]) => logDebug(...args)
39
-
40
- // Special handling for trace - capture stack at call site
41
- ;(globalThis.console as any).trace = (...args: any[]) => {
42
- const traceObj = { stack: '' }
43
- Error.captureStackTrace(traceObj, globalThis.console.trace)
44
- const cleanedStack = traceObj.stack
45
- .replace(/^Error\n/, '') // Remove "Error" header line
46
- .replace(/\?hot-hook=\d+/g, '')
47
- .replace(/file:\/\/\/?/g, '')
48
- logTrace(...args, '\n' + cleanedStack)
49
- }
50
- }
51
-
52
- export function disableConsoleCapture() {
53
- if (!consoleWrapped) return
54
- consoleWrapped = false
55
-
56
- // Restore original methods
57
- const methodsToRestore = ['log', 'info', 'warn', 'error', 'debug', 'trace'] as const
58
- for (const method of methodsToRestore) {
59
- ;(globalThis.console as any)[method] = originalConsole[method].bind(originalConsole)
60
- }
61
- }
62
-
63
- export type BuildOptions = {
64
- // Flags
65
- dry?: boolean
66
- verbose?: boolean
67
- root?: boolean
68
- strictErrors?: boolean
69
- production?: boolean
70
-
71
- // Values
72
- path: string
73
- name?: string
74
- namespace?: string
75
- world?: string
76
- clientPath?: string
77
- serverPath?: string
78
-
79
- enableSymlinks?: boolean
80
-
81
- dependencies?: [string, string][]
82
- }
83
-
84
- type SandstoneCache = {
85
- files: Record<string, string>
86
- archives?: string[]
87
- canUseSymlinks?: boolean
88
- symlinks?: string[]
89
- }
90
-
91
- export interface BuildContext {
92
- sandstoneConfig: sandstone.SandstoneConfig
93
- sandstonePack: sandstone.SandstonePack
94
- resetSandstonePack: () => void
95
- }
96
-
97
- function hash(stringToHash: string): string {
98
- return crypto.createHash('md5').update(stringToHash).digest('hex')
99
- }
100
-
101
- let cache: SandstoneCache
102
- let symlinksAvailable: boolean | undefined
103
-
104
- async function getClientPath() {
105
- function getMCPath(): string {
106
- switch (os.platform()) {
107
- case 'win32':
108
- return path.join(os.homedir(), 'AppData/Roaming/.minecraft')
109
- case 'darwin':
110
- return path.join(os.homedir(), 'Library/Application Support/minecraft')
111
- case 'linux':
112
- default:
113
- return path.join(os.homedir(), '.minecraft')
114
- }
115
- }
116
-
117
- const mcPath = getMCPath()
118
-
119
- try {
120
- await fs.stat(mcPath)
121
- } catch {
122
- log('Unable to locate the .minecraft folder. Will not be able to export to client.')
123
- return undefined
124
- }
125
-
126
- return mcPath
127
- }
128
-
129
- async function getClientWorldPath(worldName: string, minecraftPath?: string) {
130
- const mcPath = minecraftPath ?? (await getClientPath())!
131
- const savesPath = path.join(mcPath, 'saves')
132
- const worldPath = path.join(savesPath, worldName)
133
-
134
- if (!fs.existsSync(worldPath)) {
135
- const existingWorlds = (await fs.readdir(savesPath, { withFileTypes: true }))
136
- .filter((f) => f.isDirectory())
137
- .map((f) => f.name)
138
-
139
- throw new Error(
140
- `Unable to locate the "${worldPath}" folder. World ${worldName} does not exist. List of existing worlds: ${JSON.stringify(existingWorlds, null, 2)}`,
141
- )
142
- }
143
-
144
- return worldPath
145
- }
146
-
147
- // Boilerplate resources to exclude from counts
148
- const BOILERPLATE_NAMESPACES = new Set(['load', '__sandstone__'])
149
- const BOILERPLATE_FUNCTIONS = new Set(['__init__'])
150
- const BOILERPLATE_TAG = { namespace: 'minecraft', name: 'load' }
151
-
152
- function isBoilerplateResource(resource: { path?: string[]; namespace?: string }): boolean {
153
- const ns = resource.namespace || ''
154
- const pathParts = resource.path || []
155
- const name = pathParts[pathParts.length - 1] || ''
156
-
157
- // Exclude load namespace and __sandstone__ namespace
158
- if (BOILERPLATE_NAMESPACES.has(ns)) return true
159
-
160
- // Exclude __init__ functions
161
- if (BOILERPLATE_FUNCTIONS.has(name)) return true
162
-
163
- if (ns === BOILERPLATE_TAG.namespace && name === BOILERPLATE_TAG.name) return true
164
-
165
- return false
166
- }
167
-
168
- function countResources(sandstonePack: { core: { resourceNodes: Iterable<{ resource: unknown }> } }): ResourceCounts {
169
- let functions = 0
170
- let other = 0
171
-
172
- for (const node of sandstonePack.core.resourceNodes) {
173
- const resource = node.resource as { constructor?: { name?: string }; path?: string[]; namespace?: string }
174
-
175
- // Skip boilerplate resources
176
- if (isBoilerplateResource(resource)) continue
177
-
178
- // Check if it's a function (MCFunctionClass)
179
- if (resource.constructor?.name === '_RawMCFunctionClass') {
180
- functions++
181
- } else {
182
- other++
183
- }
184
- }
185
-
186
- return { functions, other }
187
- }
188
-
189
- function splitPath(split: string) {
190
- let parts: string[] = []
191
- let current = split
192
-
193
- while (current !== 'C:') {
194
- let dirname = path.dirname(current)
195
- if (dirname.endsWith('\\')) {
196
- dirname = dirname.slice(0, -1)
197
- }
198
- parts.unshift(current.replace(`${dirname}${path.sep}`, ''))
199
- current = dirname
200
- }
201
- parts.unshift('C:')
202
- return parts
203
- }
204
-
205
- async function handleSymlink(folder: string, packName: string, cache: SandstoneCache, minecraftPath: string, targetPath: string, linkPath: string) {
206
- let rawPath = path.resolve(path.join(folder))
207
- let sep: string = path.sep
208
- if (os.platform() === 'win32') {
209
- sep = `${path.sep}${path.sep}`
210
-
211
- rawPath = splitPath(rawPath).join(sep)
212
- }
213
- const allowPath = `[glob]${rawPath}${sep}**${sep}*`
214
-
215
- const allowedList = path.join(minecraftPath, 'allowed_symlinks.txt')
216
-
217
- const comment = `# Sandstone Pack: ${packName}\n`
218
- try {
219
- const currentlyAllowed = await fs.readFile(allowedList, 'utf-8')
220
-
221
- if (!currentlyAllowed.includes(allowPath)) {
222
- log('[handleSymlink] Adding workspace to allowed_symlinks.txt at minecraft path. If the game is running please restart it.')
223
- await fs.writeFile(allowedList, `${currentlyAllowed}\n#\n${comment}${allowPath}`)
224
- } else {
225
- log('[handleSymlink] Workspace is already in allowed_symlinks.txt at minecraft path, skipping...')
226
- }
227
- } catch (e) {
228
- log('[handleSymlink] Creating allowed_symlinks.txt at minecraft path. If the game is running please restart it.')
229
- await fs.writeFile(allowedList, `${comment}${allowPath}`)
230
- }
231
-
232
- let skip = false
233
- let errored = false
234
- try {
235
- const stats = await fs.lstat(linkPath)
236
- if (stats.isSymbolicLink() && await fs.readlink(linkPath) === path.resolve(targetPath)) {
237
- log('[handleSymlink] Symlink already created, skipping...')
238
- skip = true
239
- } else {
240
- errored = true
241
- }
242
- } catch {}
243
- if (errored) {
244
- throw new Error(`Tried to add a symlink at "${linkPath}",\n encountered an existing FS entry.`)
245
- }
246
-
247
- if (!skip) {
248
- log(`[handleSymlink] Creating symlink for ${targetPath.replace(`${path.dirname(targetPath)}${path.sep}`, '')}`)
249
- await fs.symlink(path.resolve(targetPath), linkPath)
250
- }
251
- cache.symlinks ??= []
252
- cache.symlinks.push(linkPath)
253
- }
254
-
255
- export async function loadBuildContext(
256
- cliOptions: BuildOptions,
257
- folder: string,
258
- ): Promise<BuildContext> {
259
- // Load sandstone.config.ts
260
- const configPath = path.join(folder, 'sandstone.config.ts')
261
- const configUrl = pathToFileURL(configPath).toString()
262
- const sandstoneConfig = (await import(configUrl)).default
263
-
264
- // Build the context for sandstone
265
- const namespace = cliOptions.namespace || sandstoneConfig.namespace
266
- const conflictStrategies: NonNullable<SandstoneContext['conflictStrategies']> = {}
267
-
268
- if (sandstoneConfig.onConflict) {
269
- for (const [resource, strategy] of Object.entries(sandstoneConfig.onConflict)) {
270
- conflictStrategies[resource] = strategy as NonNullable<SandstoneContext['conflictStrategies']>[string]
271
- }
272
- }
273
-
274
- // Import sandstone from the project's node_modules, not the CLI's
275
- // This ensures we use the same module instance as the user code
276
- const sandstoneUrl = pathToFileURL(path.join(folder, 'node_modules', 'sandstone', 'dist', 'index.js'))
277
- /* @ts-ignore */
278
- const { createSandstonePack, resetSandstonePack } = (await import(sandstoneUrl)) as typeof sandstone
279
-
280
- const context: SandstoneContext = {
281
- workingDir: folder,
282
- namespace,
283
- packUid: sandstoneConfig.packUid,
284
- packOptions: sandstoneConfig.packs,
285
- conflictStrategies,
286
- loadVersion: sandstoneConfig.loadVersion,
287
- }
288
-
289
- // Create the pack with context
290
- const sandstonePack = createSandstonePack(context)
291
-
292
- return { sandstoneConfig, sandstonePack, resetSandstonePack }
293
- }
294
-
295
- interface BuildProjectResult {
296
- resourceCounts: ResourceCounts
297
- sandstoneConfig: sandstone.SandstoneConfig
298
- sandstonePack: sandstone.SandstonePack
299
- resetSandstonePack: () => void
300
- }
301
-
302
- async function _buildProject(
303
- cliOptions: BuildOptions,
304
- folder: string,
305
- silent = false,
306
- existingContext?: BuildContext,
307
- watching = false
308
- ): Promise<BuildProjectResult | undefined> {
309
- // Read project package.json to get entrypoint
310
- const packageJsonPath = path.join(folder, 'package.json')
311
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'))
312
-
313
- // Get the entrypoint from the "module" field
314
- const entrypoint = packageJson.module
315
- if (!entrypoint) {
316
- throw new Error(
317
- 'No "module" field found in package.json. Please specify the entrypoint for your pack code.',
318
- )
319
- }
320
-
321
- const entrypointPath = path.join(folder, entrypoint)
322
-
323
- // Load or use existing context
324
- const { sandstoneConfig, sandstonePack, resetSandstonePack } = existingContext ??
325
- await loadBuildContext(cliOptions, folder)
326
-
327
- // Reset pack state before each build
328
- resetSandstonePack()
329
-
330
- const { scripts, resources } = sandstoneConfig
331
- const saveOptions = sandstoneConfig.saveOptions || {}
332
-
333
- const outputFolder = path.join(folder, '.sandstone', 'output')
334
-
335
- // Resolve options
336
- const worldName: string | undefined = cliOptions.world || saveOptions.world
337
- const root: boolean | undefined = cliOptions.root !== undefined ? cliOptions.root : saveOptions.root
338
-
339
- // Only use explicitly configured client path for now
340
- // We'll auto-detect after save() if there are client-side packs that need exporting
341
- let clientPath = !cliOptions.production
342
- ? cliOptions.clientPath || saveOptions.clientPath
343
- : undefined
344
-
345
- if (worldName && !cliOptions.production) {
346
- // Need client path for world export
347
- clientPath ??= await getClientPath()
348
- if (clientPath) {
349
- await getClientWorldPath(worldName, clientPath)
350
- }
351
- } else if (root && !cliOptions.production) {
352
- // Need client path for root export
353
- clientPath ??= await getClientPath()
354
- }
355
-
356
- const serverPath = !cliOptions.production
357
- ? cliOptions.serverPath || saveOptions.serverPath
358
- : undefined
359
- const packName: string = cliOptions.name ?? sandstoneConfig.name
360
-
361
- if (worldName && root) {
362
- throw new Error("Expected only 'world' or 'root'. Got both.")
363
- }
364
-
365
- // Run beforeAll script
366
- await scripts?.beforeAll?.()
367
-
368
- // Import user code (this executes their pack definitions)
369
- if (!silent) {
370
- log('Compiling source...')
371
- }
372
-
373
- try {
374
- if (await fs.pathExists(entrypointPath)) {
375
- const isBun = Object.hasOwn(globalThis, 'Bun')
376
- const entrypointUrl = pathToFileURL(entrypointPath).toString()
377
-
378
- if (watching && !isBun) {
379
- // Hot-hook for Node.js - only this should be hot reloaded
380
- // Bun doesn't support hot-hook, we clear require.cache instead in watch.ts
381
- await import(entrypointUrl, { with: { hot: 'true' } })
382
- } else {
383
- await import(entrypointUrl)
384
- }
385
- }
386
- } catch (e: any) {
387
- const errorMsg = `While loading "${entrypointPath}":\n${e.stack || e.message || e}`
388
- if (silent) {
389
- log('BuildError:', errorMsg)
390
- } else {
391
- console.error(chalk.bgRed.white('BuildError') + chalk.gray(':'), errorMsg)
392
- }
393
- throw e // Re-throw for buildCommand to handle
394
- }
395
-
396
- // Add dependencies if specified
397
- if (cliOptions.dependencies) {
398
- for (const dependency of cliOptions.dependencies) {
399
- sandstonePack.core.depend(...dependency)
400
- }
401
- }
402
-
403
- // Setup cache
404
- const newCache: SandstoneCache = { files: {}, archives: [] }
405
- const cacheFile = path.join(folder, '.sandstone', 'cache.json')
406
- // Track which pack types have changed files
407
- const changedPackTypes = new Set<string>()
408
- // Track directories containing new files
409
- const newDirs = new Set<string>()
410
-
411
- if (cache === undefined) {
412
- try {
413
- const fileRead = await fs.readFile(cacheFile, 'utf8')
414
- if (fileRead) {
415
- const parsed = JSON.parse(fileRead)
416
- // Handle legacy cache format (plain Record<string, string>)
417
- if (parsed.files) {
418
- cache = parsed
419
- } else {
420
- cache = { files: parsed }
421
- }
422
- }
423
- } catch {
424
- cache = { files: {} }
425
- }
426
- }
427
-
428
- // Check symlink availability (use cached value if available)
429
- if (symlinksAvailable === undefined) {
430
- if (cache.canUseSymlinks !== undefined) {
431
- symlinksAvailable = cache.canUseSymlinks
432
- } else {
433
- symlinksAvailable = await canUseSymlinks()
434
- }
435
- }
436
- newCache.canUseSymlinks = symlinksAvailable
437
-
438
- // Run beforeSave script
439
- await scripts?.beforeSave?.()
440
-
441
- // Auto-register pack types if existing resources are present
442
- // This ensures handleResources() is called even when no resources are created programmatically
443
- const resourcesFolder = path.join(folder, 'resources')
444
- if (await fs.pathExists(path.join(resourcesFolder, 'resourcepack'))) {
445
- const files = await fs.readdir(path.join(resourcesFolder, 'resourcepack'))
446
- if (files.length > 0) {
447
- sandstonePack.resourcePack()
448
- }
449
- }
450
- if (await fs.pathExists(path.join(resourcesFolder, 'datapack'))) {
451
- const files = await fs.readdir(path.join(resourcesFolder, 'datapack'))
452
- if (files.length > 0) {
453
- sandstonePack.dataPack()
454
- }
455
- }
456
-
457
- // File exclusion setup
458
- const excludeOption = resources?.exclude
459
- const fileExclusions = excludeOption
460
- ? {
461
- generated: ('generated' in excludeOption ? excludeOption.generated : excludeOption) as RegExp[] | undefined,
462
- existing: ('existing' in excludeOption ? excludeOption.existing : excludeOption) as RegExp[] | undefined,
463
- }
464
- : false
465
-
466
- const fileHandlers = (resources?.handle as {
467
- path: RegExp
468
- callback: (contents: string | Buffer | Promise<Buffer>) => Promise<Buffer>
469
- }[]) || false
470
-
471
- // Save the pack
472
- const packTypes = await sandstonePack.save({
473
- dry: cliOptions.dry ?? false,
474
- verbose: cliOptions.verbose ?? false,
475
-
476
- fileHandler: (saveOptions.customFileHandler as ((relativePath: string, content: any) => Promise<void>) | undefined) ??
477
- (async (relativePath: string, content: any) => {
478
- let pathPass = true
479
- if (fileExclusions && fileExclusions.generated) {
480
- for (const exclude of fileExclusions.generated) {
481
- if (!Array.isArray(exclude)) {
482
- pathPass = !exclude.test(relativePath)
483
- }
484
- }
485
- }
486
-
487
- if (fileHandlers) {
488
- for (const handler of fileHandlers) {
489
- if (handler.path.test(relativePath)) {
490
- content = await handler.callback(content)
491
- }
492
- }
493
- }
494
-
495
- if (pathPass) {
496
- const hashValue = hash(content + relativePath)
497
- newCache.files[relativePath] = hashValue
498
- // Track parent directories
499
- for (let dir = path.dirname(relativePath); dir && dir !== '.'; dir = path.dirname(dir)) {
500
- newDirs.add(dir)
501
- }
502
-
503
- if (cache.files[relativePath] === hashValue) {
504
- return
505
- }
506
-
507
- // Track that this pack type has changed
508
- const packTypeDir = relativePath.split(/[/\\]/)[0]
509
- changedPackTypes.add(packTypeDir)
510
-
511
- const realPath = path.join(outputFolder, relativePath)
512
- await fs.ensureDir(path.dirname(realPath))
513
- return await fs.writeFile(realPath, content)
514
- }
515
- }),
516
- })
517
-
518
- // Handle resources folder
519
- async function handleResources(packType: string) {
520
- const working = path.join(folder, 'resources', packType)
521
-
522
- if (!(await fs.pathExists(working))) {
523
- return
524
- }
525
-
526
- const walk = async (dir: string): Promise<string[]> => {
527
- const files: string[] = []
528
- const entries = await fs.readdir(dir, { withFileTypes: true })
529
- for (const entry of entries) {
530
- const fullPath = path.join(dir, entry.name)
531
- if (entry.isDirectory()) {
532
- files.push(...(await walk(fullPath)))
533
- } else {
534
- files.push(fullPath)
535
- }
536
- }
537
- return files
538
- }
539
-
540
- for (const file of await walk(working)) {
541
- const relativePath = path.join(packType, file.substring(working.length + 1))
542
-
543
- let pathPass = true
544
- if (fileExclusions && fileExclusions.existing) {
545
- for (const exclude of fileExclusions.existing) {
546
- pathPass = Array.isArray(exclude) ? !exclude[0].test(relativePath) : !exclude.test(relativePath)
547
- }
548
- }
549
-
550
- if (!pathPass) continue
551
-
552
- try {
553
- let content = await fs.readFile(file)
554
-
555
- if (fileHandlers) {
556
- for (const handler of fileHandlers) {
557
- if (handler.path.test(relativePath)) {
558
- content = (await handler.callback(content)) as Buffer<ArrayBuffer>
559
- }
560
- }
561
- }
562
-
563
- const hashValue = hash(content + relativePath)
564
- newCache.files[relativePath] = hashValue
565
-
566
- for (let dir = path.dirname(relativePath); dir && dir !== '.'; dir = path.dirname(dir)) {
567
- if (newDirs.has(dir)) {
568
- break
569
- } else {
570
- newDirs.add(dir)
571
- }
572
- }
573
-
574
- if (cache.files[relativePath] !== hashValue) {
575
- // Track that this pack type has changed
576
- changedPackTypes.add(packType)
577
-
578
- const realPath = path.join(outputFolder, relativePath)
579
- await fs.ensureDir(path.dirname(realPath))
580
- await fs.writeFile(realPath, content)
581
- }
582
- } catch {}
583
- }
584
- }
585
-
586
- // Archive output if needed
587
- async function archiveOutput(packType: any): Promise<boolean> {
588
- const input = path.join(outputFolder, packType.type)
589
-
590
- const files = await fs.readdir(input).catch(() => [])
591
- if (files.length === 0) return false
592
-
593
- const archiveName = `${packName}_${packType.type}.zip`
594
- newCache.archives!.push(archiveName)
595
-
596
- const archive = new AdmZip()
597
- await archive.addLocalFolderPromise(input, {})
598
- await fs.ensureDir(path.join(outputFolder, 'archives'))
599
- await archive.writeZipPromise(
600
- path.join(outputFolder, 'archives', archiveName),
601
- { overwrite: true },
602
- )
603
-
604
- return true
605
- }
606
-
607
- // Export to client/server
608
- if (!cliOptions.production) {
609
- // Check if there are any client-side packs that need exporting
610
- // If so and clientPath not set, try to find it now (after dependencies resolved)
611
- const packTypesArray = [...packTypes]
612
- const hasClientPacks = packTypesArray.some(([, pt]) => pt.networkSides === 'client')
613
- if (hasClientPacks && !clientPath && (root || worldName)) {
614
- clientPath = await getClientPath()
615
- }
616
-
617
- // When no world/root specified, only export client-side packs (resource packs + dependencies)
618
- const resourcePackOnlyExport = !worldName && !root
619
-
620
- for (const [, packType] of packTypesArray) {
621
- const outputPath = path.join(outputFolder, packType.type)
622
- await fs.ensureDir(outputPath)
623
-
624
- if (packType.handleOutput) {
625
- await packType.handleOutput(
626
- 'output',
627
- (async (relativePath: string, encoding: BufferEncoding = 'utf8') =>
628
- await fs.readFile(path.join(outputPath, relativePath), encoding)) as unknown as handlerReadFile,
629
- async (relativePath: string, contents: any) => {
630
- if (contents === undefined) {
631
- await fs.unlink(path.join(outputPath, relativePath))
632
- } else {
633
- await fs.writeFile(path.join(outputPath, relativePath), contents.replaceAll('"min_version"', '"min_format"').replace('"max_version"', '"max_format"'))
634
- }
635
- },
636
- )
637
- }
638
-
639
- await handleResources(packType.type)
640
-
641
- // Skip archive and export if no files in this pack type changed
642
- if (!changedPackTypes.has(packType.type)) {
643
- continue
644
- }
645
-
646
- let archivedOutput = false
647
- if (packType.archiveOutput && saveOptions.exportZips) {
648
- archivedOutput = await archiveOutput(packType)
649
- }
650
-
651
- // Handle client export
652
- // Skip non-client packs (datapacks) when in resource pack only mode (no world/root specified)
653
- if (clientPath && !(resourcePackOnlyExport && packType.networkSides !== 'client')) {
654
- let fullClientPath: string
655
-
656
- // Only export the resource pack to `$worldName$/resources.zip` if exportZips is on
657
- if (worldName && (packType.type !== 'resourcepack' || saveOptions.exportZips)) {
658
- fullClientPath = path.join(clientPath, packType.clientPath)
659
- .replace('$packName$', packName)
660
- .replace('$worldName$', worldName)
661
- } else {
662
- fullClientPath = path.join(clientPath, packType.rootPath).replace('$packName$', packName)
663
- }
664
-
665
- if (packType.archiveOutput && archivedOutput && saveOptions.exportZips) {
666
- const archivePath = path.join(outputFolder, 'archives', `${packName}_${packType.type}.zip`)
667
- await fs.copyFile(archivePath, `${fullClientPath}.zip`)
668
- } else if (symlinksAvailable) {
669
- if (cache.symlinks === undefined || !cache.symlinks.includes(fullClientPath)) {
670
- await handleSymlink(
671
- folder,
672
- packName,
673
- newCache,
674
- clientPath,
675
- outputPath,
676
- fullClientPath,
677
- )
678
- }
679
- } else {
680
- await fs.remove(fullClientPath)
681
- await fs.copy(outputPath, fullClientPath)
682
- }
683
- }
684
-
685
- // Handle server export (skip client-only packs like resource packs)
686
- if (serverPath && packType.networkSides === 'server') {
687
- const fullServerPath = path.join(serverPath, packType.serverPath).replace('$packName$', packName)
688
-
689
- if (packType.archiveOutput && archivedOutput && saveOptions.exportZips) {
690
- const archivePath = path.join(outputFolder, 'archives', `${packName}_${packType.type}.zip`)
691
- await fs.copyFile(archivePath, `${fullServerPath}.zip`)
692
- } else if (symlinksAvailable) {
693
- if (cache.symlinks === undefined || !cache.symlinks.includes(fullServerPath)) {
694
- await handleSymlink(
695
- folder,
696
- packName,
697
- newCache,
698
- serverPath,
699
- outputPath,
700
- fullServerPath,
701
- )
702
- }
703
- } else {
704
- await fs.remove(fullServerPath)
705
- await fs.copy(outputPath, fullServerPath)
706
- }
707
- }
708
- }
709
- } else {
710
- // Production mode
711
- for await (const [, packType] of packTypes) {
712
- const outputPath = path.join(outputFolder, packType.type)
713
-
714
- if (packType.handleOutput) {
715
- await packType.handleOutput(
716
- 'output',
717
- (async (relativePath: string, encoding: BufferEncoding = 'utf8') =>
718
- await fs.readFile(path.join(outputPath, relativePath), encoding)) as unknown as handlerReadFile,
719
- async (relativePath: string, contents: any) => {
720
- if (contents === undefined) {
721
- await fs.unlink(path.join(outputPath, relativePath))
722
- } else {
723
- await fs.writeFile(path.join(outputPath, relativePath), contents.replaceAll('"min_version"', '"min_format"').replace('"max_version"', '"max_format"'))
724
- }
725
- },
726
- )
727
- }
728
-
729
- await handleResources(packType.type)
730
- }
731
- }
732
-
733
- // Clean up old files, directories, and symlinks not in new cache
734
- if (cliOptions.dry !== true) {
735
- for (const file of Object.keys(cache.files)) {
736
- if (!(file in newCache.files)) {
737
- await fs.rm(path.join(outputFolder, file))
738
-
739
- let dir: string | undefined = undefined
740
- for (const segment of split(new RegExp(RegExp.escape(path.sep)), path.dirname(file))) {
741
- dir = dir === undefined ? segment : path.join(dir, segment)
742
-
743
- if (!newDirs.has(dir)) {
744
- await fs.rm(path.join(outputFolder, dir), { force: true, recursive: true })
745
- break
746
- }
747
- }
748
- }
749
- }
750
-
751
- // Clean up old archives
752
- if (cache.archives) {
753
- const archivesDir = path.join(outputFolder, 'archives')
754
- if (newCache.archives === undefined || newCache.archives.length === 0) {
755
- await fs.rm(archivesDir, { force: true, recursive: true })
756
- }
757
- for (const archive of cache.archives) {
758
- if (!newCache.archives!.includes(archive)) {
759
- await fs.rm(path.join(archivesDir, archive))
760
- }
761
- }
762
- }
763
-
764
- if (cache.symlinks) {
765
- const newSymlinks = new Set(newCache.symlinks)
766
-
767
- for (const symlink of cache.symlinks) {
768
- if (!newSymlinks.has(symlink)) {
769
- await fs.rm(symlink)
770
- }
771
- }
772
- }
773
-
774
- // Update cache
775
- cache = newCache
776
- await fs.ensureDir(path.dirname(cacheFile))
777
- await fs.writeFile(cacheFile, JSON.stringify(cache))
778
- }
779
-
780
- // Run afterAll script
781
- await scripts?.afterAll?.()
782
-
783
- // Count resources (excluding boilerplate)
784
- const resourceCounts = countResources(sandstonePack)
785
-
786
- const exports = [clientPath && 'client', serverPath && 'server'].filter(Boolean).join(' & ') || false
787
- const countMsg = `${resourceCounts.functions} functions, ${resourceCounts.other} other resources`
788
- if (!silent) {
789
- log(`Pack(s) compiled! (${countMsg})${exports ? ` Exported to ${exports}.` : ''}`)
790
- }
791
-
792
- return { resourceCounts, sandstoneConfig, sandstonePack, resetSandstonePack }
793
- }
794
-
795
- export async function _buildCommand(
796
- opts: BuildOptions,
797
- _folder?: string,
798
- existingContext?: BuildContext,
799
- watching = false
800
- ): Promise<BuildResult> {
801
- const folder = _folder ?? opts.path
802
-
803
- try {
804
- const result = await _buildProject(opts, folder, true, existingContext, watching)
805
- return {
806
- success: true,
807
- resourceCounts: result?.resourceCounts ?? { functions: 0, other: 0 },
808
- timestamp: Date.now(),
809
- sandstoneConfig: result?.sandstoneConfig,
810
- sandstonePack: result?.sandstonePack,
811
- resetSandstonePack: result?.resetSandstonePack,
812
- }
813
- } catch (err: any) {
814
- const errorMessage = err.message || String(err)
815
- // Always include stack trace for better debugging - format paths for terminal clickability
816
- const stack = err.stack || ''
817
- // Clean up stack trace: remove ?hot-hook query params and convert file:// URLs to paths
818
- const cleanedStack = stack
819
- .replace(/\?hot-hook=\d+/g, '') // Remove hot-hook cache busting params
820
- .replace(/file:\/\/\//g, '') // Convert file:/// URLs to paths (Windows)
821
- .replace(/file:\/\//g, '') // Convert file:// URLs to paths (Unix)
822
- const formattedError = cleanedStack ? `${errorMessage}\n${cleanedStack}` : errorMessage
823
- log('Build failed:', errorMessage)
824
- return {
825
- success: false,
826
- error: formattedError,
827
- resourceCounts: { functions: 0, other: 0 },
828
- timestamp: Date.now(),
829
- }
830
- }
831
- }
832
-
833
- export async function buildCommand(opts: BuildOptions, _?: string): Promise<void>
834
- export async function buildCommand(opts: BuildOptions, _folder: string | undefined, silent: true): Promise<BuildResult>
835
- export async function buildCommand(opts: BuildOptions, _folder?: string, silent = false): Promise<BuildResult | void> {
836
- // Commander passes Command object as second arg, so check for string explicitly
837
- const folder = (typeof _folder === 'string') ? _folder : opts.path
838
-
839
- // Initialize logger without file for build mode
840
- initLoggerNoFile()
841
- setSilent(silent)
842
-
843
- try {
844
- const result = await _buildProject(opts, folder, silent)
845
- if (silent) {
846
- return {
847
- success: true,
848
- resourceCounts: result?.resourceCounts ?? { functions: 0, other: 0 },
849
- timestamp: Date.now(),
850
- sandstoneConfig: result?.sandstoneConfig,
851
- sandstonePack: result?.sandstonePack,
852
- resetSandstonePack: result?.resetSandstonePack,
853
- }
854
- }
855
- } catch (err: any) {
856
- const errorMessage = err.message || String(err)
857
- if (!silent) {
858
- // Error already logged by _buildProject with highlighting
859
- process.exit(1)
860
- }
861
- log('Build failed:', errorMessage)
862
- return {
863
- success: false,
864
- error: err.stack || errorMessage,
865
- resourceCounts: { functions: 0, other: 0 },
866
- timestamp: Date.now(),
867
- }
868
- }
869
- }