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.
@@ -0,0 +1,568 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import fs from 'fs-extra'
4
+ import chalk from 'chalk'
5
+ import { split } from 'obliterator'
6
+
7
+ import type { BuildResult, ResourceCounts } from '../../ui/types.js'
8
+ import { log, initLoggerNoFile, setSilent } from '../../ui/logger.js'
9
+ import { hash } from '../../utils.js'
10
+
11
+ import {
12
+ type SandstoneCache,
13
+ checkSymlinksAvailable,
14
+ getClientPath,
15
+ getClientWorldPath,
16
+ createArchive,
17
+ preserveSymlink,
18
+ exportPack,
19
+ runExportHandler,
20
+ getExportPath,
21
+ cleanupOldSymlinks,
22
+ cleanupOldArchives,
23
+ } from './export.js'
24
+
25
+ import {
26
+ type FileExclusions,
27
+ type FileHandler,
28
+ autoRegisterPackTypes,
29
+ processExternalResources,
30
+ } from './externalResources.js'
31
+
32
+ import type * as sandstone from 'sandstone'
33
+ import type { handlerReadFile, PackType } from 'sandstone/pack'
34
+
35
+ type SandstoneContext = ReturnType<typeof sandstone['getSandstoneContext']>
36
+
37
+ declare global {
38
+ interface RegExpConstructor {
39
+ escape(str: string): string;
40
+ }
41
+ }
42
+
43
+ export type BuildOptions = {
44
+ // Flags
45
+ dry?: boolean
46
+ verbose?: boolean
47
+ root?: boolean
48
+ strictErrors?: boolean
49
+ production?: boolean
50
+
51
+ // Values
52
+ path: string
53
+ name?: string
54
+ namespace?: string
55
+ world?: string
56
+ clientPath?: string
57
+ serverPath?: string
58
+
59
+ enableSymlinks?: boolean
60
+
61
+ dependencies?: [string, string][]
62
+ }
63
+
64
+ export interface BuildContext {
65
+ sandstoneConfig: sandstone.SandstoneConfig
66
+ sandstonePack: sandstone.SandstonePack
67
+ resetSandstonePack: () => void
68
+ }
69
+
70
+ // Cache management
71
+ let cache: SandstoneCache = { files: {} }
72
+
73
+ async function loadCache(cacheFile: string): Promise<SandstoneCache> {
74
+ if (Object.keys(cache.files).length > 0) {
75
+ return cache
76
+ }
77
+
78
+ try {
79
+ const fileRead = await fs.readFile(cacheFile, 'utf8')
80
+ if (fileRead) {
81
+ const parsed = JSON.parse(fileRead)
82
+ cache = parsed.files ? parsed : { files: parsed }
83
+ }
84
+ } catch {
85
+ cache = { files: {} }
86
+ }
87
+
88
+ return cache
89
+ }
90
+
91
+ async function saveCache(cacheFile: string, newCache: SandstoneCache) {
92
+ cache = newCache
93
+ await fs.ensureDir(path.dirname(cacheFile))
94
+ await fs.writeFile(cacheFile, JSON.stringify(cache))
95
+ }
96
+
97
+ // Boilerplate resources to exclude from counts
98
+ const BOILERPLATE_NAMESPACES = new Set(['load', '__sandstone__'])
99
+ const BOILERPLATE_FUNCTIONS = new Set(['__init__'])
100
+ const BOILERPLATE_TAG = { namespace: 'minecraft', name: 'load' }
101
+
102
+ function isBoilerplateResource(resource: { path?: string[]; namespace?: string }): boolean {
103
+ const ns = resource.namespace || ''
104
+ const pathParts = resource.path || []
105
+ const name = pathParts[pathParts.length - 1] || ''
106
+
107
+ if (BOILERPLATE_NAMESPACES.has(ns)) return true
108
+ if (BOILERPLATE_FUNCTIONS.has(name)) return true
109
+ if (ns === BOILERPLATE_TAG.namespace && name === BOILERPLATE_TAG.name) return true
110
+
111
+ return false
112
+ }
113
+
114
+ function countResources(sandstonePack: { core: { resourceNodes: Iterable<{ resource: unknown }> } }): ResourceCounts {
115
+ let functions = 0
116
+ let other = 0
117
+
118
+ for (const node of sandstonePack.core.resourceNodes) {
119
+ const resource = node.resource as { constructor?: { name?: string }; path?: string[]; namespace?: string }
120
+
121
+ if (isBoilerplateResource(resource)) continue
122
+
123
+ if (resource.constructor?.name === '_RawMCFunctionClass') {
124
+ functions++
125
+ } else {
126
+ other++
127
+ }
128
+ }
129
+
130
+ return { functions, other }
131
+ }
132
+
133
+ // Process pack type's generated output (post-processing)
134
+ async function processPackTypeOutput(
135
+ packType: PackType,
136
+ outputPath: string
137
+ ) {
138
+ await fs.ensureDir(outputPath)
139
+
140
+ if (packType.handleOutput) {
141
+ await packType.handleOutput(
142
+ 'output',
143
+ (async (relativePath: string, encoding: BufferEncoding = 'utf8') =>
144
+ await fs.readFile(path.join(outputPath, relativePath), encoding)) as unknown as handlerReadFile,
145
+ async (relativePath: string, contents: any) => {
146
+ if (contents === undefined) {
147
+ await fs.unlink(path.join(outputPath, relativePath))
148
+ } else {
149
+ await fs.writeFile(
150
+ path.join(outputPath, relativePath),
151
+ contents
152
+ )
153
+ }
154
+ },
155
+ )
156
+ }
157
+ }
158
+
159
+ export async function loadBuildContext(
160
+ cliOptions: BuildOptions,
161
+ folder: string,
162
+ ): Promise<BuildContext> {
163
+ const configPath = path.join(folder, 'sandstone.config.ts')
164
+ const configUrl = pathToFileURL(configPath).toString()
165
+ const sandstoneConfig = (await import(configUrl)).default
166
+
167
+ const namespace = cliOptions.namespace || sandstoneConfig.namespace
168
+ const conflictStrategies: NonNullable<SandstoneContext['conflictStrategies']> = {}
169
+
170
+ if (sandstoneConfig.onConflict) {
171
+ for (const [resource, strategy] of Object.entries(sandstoneConfig.onConflict)) {
172
+ conflictStrategies[resource] = strategy as NonNullable<SandstoneContext['conflictStrategies']>[string]
173
+ }
174
+ }
175
+
176
+ const sandstoneUrl = pathToFileURL(path.join(folder, 'node_modules', 'sandstone', 'dist', 'index.js'))
177
+ /* @ts-ignore */
178
+ const { createSandstonePack, resetSandstonePack } = (await import(sandstoneUrl)) as typeof sandstone
179
+
180
+ const context: SandstoneContext = {
181
+ workingDir: folder,
182
+ namespace,
183
+ packUid: sandstoneConfig.packUid,
184
+ packOptions: sandstoneConfig.packs,
185
+ conflictStrategies,
186
+ loadVersion: sandstoneConfig.loadVersion,
187
+ }
188
+
189
+ const sandstonePack = createSandstonePack(context)
190
+
191
+ return { sandstoneConfig, sandstonePack, resetSandstonePack }
192
+ }
193
+
194
+ interface BuildProjectResult {
195
+ resourceCounts: ResourceCounts
196
+ sandstoneConfig: sandstone.SandstoneConfig
197
+ sandstonePack: sandstone.SandstonePack
198
+ resetSandstonePack: () => void
199
+ }
200
+
201
+ async function _buildProject(
202
+ cliOptions: BuildOptions,
203
+ folder: string,
204
+ silent = false,
205
+ existingContext?: BuildContext,
206
+ watching = false
207
+ ): Promise<BuildProjectResult | undefined> {
208
+ // Read project package.json to get entrypoint
209
+ const packageJsonPath = path.join(folder, 'package.json')
210
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'))
211
+
212
+ const entrypoint = packageJson.module
213
+ if (!entrypoint) {
214
+ throw new Error(
215
+ 'No "module" field found in package.json. Please specify the entrypoint for your pack code.',
216
+ )
217
+ }
218
+
219
+ const entrypointPath = path.join(folder, entrypoint)
220
+
221
+ // Load or use existing context
222
+ const { sandstoneConfig, sandstonePack, resetSandstonePack } = existingContext ??
223
+ await loadBuildContext(cliOptions, folder)
224
+
225
+ resetSandstonePack()
226
+
227
+ const { scripts, resources } = sandstoneConfig
228
+ const saveOptions = sandstoneConfig.saveOptions || {}
229
+
230
+ const outputFolder = path.join(folder, '.sandstone', 'output')
231
+
232
+ // Resolve export options
233
+ const worldName: string | undefined = cliOptions.world || saveOptions.world
234
+ const root: boolean | undefined = cliOptions.root !== undefined ? cliOptions.root : saveOptions.root
235
+
236
+ let clientPath = !cliOptions.production
237
+ ? cliOptions.clientPath || saveOptions.clientPath
238
+ : undefined
239
+
240
+ if (worldName && !cliOptions.production) {
241
+ clientPath ??= await getClientPath()
242
+ if (clientPath) {
243
+ await getClientWorldPath(worldName, clientPath)
244
+ }
245
+ } else if (root && !cliOptions.production) {
246
+ clientPath ??= await getClientPath()
247
+ }
248
+
249
+ const serverPath = !cliOptions.production
250
+ ? cliOptions.serverPath || saveOptions.serverPath
251
+ : undefined
252
+ const packName: string = cliOptions.name ?? sandstoneConfig.name
253
+
254
+ if (worldName && root) {
255
+ throw new Error("Expected only 'world' or 'root'. Got both.")
256
+ }
257
+
258
+ // Run beforeAll script
259
+ await scripts?.beforeAll?.()
260
+
261
+ // Import user code
262
+ if (!silent) {
263
+ log('Compiling source...')
264
+ }
265
+
266
+ try {
267
+ if (await fs.pathExists(entrypointPath)) {
268
+ const isBun = Object.hasOwn(globalThis, 'Bun')
269
+ const entrypointUrl = pathToFileURL(entrypointPath).toString()
270
+
271
+ if (watching && !isBun) {
272
+ await import(entrypointUrl, { with: { hot: 'true' } })
273
+ } else {
274
+ await import(entrypointUrl)
275
+ }
276
+ }
277
+ } catch (e: any) {
278
+ // Enhance error with context, let callers handle logging
279
+ e.message = `While loading "${entrypointPath}":\n${e.message || e}`
280
+ throw e
281
+ }
282
+
283
+ // Add dependencies if specified
284
+ if (cliOptions.dependencies) {
285
+ for (const dependency of cliOptions.dependencies) {
286
+ sandstonePack.core.depend(...dependency)
287
+ }
288
+ }
289
+
290
+ // Setup cache
291
+ const cacheFile = path.join(folder, '.sandstone', 'cache.json')
292
+ const oldCache = await loadCache(cacheFile)
293
+ const newCache: SandstoneCache = { files: {}, archives: [] }
294
+
295
+ const changedPackTypes = new Set<string>()
296
+ const newDirs = new Set<string>()
297
+
298
+ // Check symlink availability
299
+ newCache.canUseSymlinks = await checkSymlinksAvailable(oldCache.canUseSymlinks)
300
+
301
+ // Run beforeSave script
302
+ await scripts?.beforeSave?.()
303
+
304
+ // Auto-register pack types if existing resources are present
305
+ await autoRegisterPackTypes(folder, sandstonePack)
306
+
307
+ // File exclusion setup
308
+ const excludeOption = resources?.exclude
309
+ const fileExclusions: FileExclusions = excludeOption
310
+ ? {
311
+ generated: ('generated' in excludeOption ? excludeOption.generated : excludeOption) as RegExp[] | undefined,
312
+ existing: ('existing' in excludeOption ? excludeOption.existing : excludeOption) as RegExp[] | undefined,
313
+ }
314
+ : false
315
+
316
+ const fileHandlers: FileHandler[] | false = (resources?.handle as FileHandler[]) || false
317
+
318
+ // Save the pack
319
+ const packTypes = await sandstonePack.save({
320
+ dry: cliOptions.dry ?? false,
321
+ verbose: cliOptions.verbose ?? false,
322
+
323
+ // TODO: Implement `contentSummary` and remove this typecast
324
+ fileHandler: (saveOptions.customFileHandler as ((relativePath: string, content: any) => Promise<void>) | undefined) ??
325
+ (async (relativePath: string, content: any) => {
326
+ let pathPass = true
327
+ if (fileExclusions && fileExclusions.generated) {
328
+ for (const exclude of fileExclusions.generated) {
329
+ if (!Array.isArray(exclude)) {
330
+ pathPass = !exclude.test(relativePath)
331
+ }
332
+ }
333
+ }
334
+
335
+ if (fileHandlers) {
336
+ for (const handler of fileHandlers) {
337
+ if (handler.path.test(relativePath)) {
338
+ content = await handler.callback(content)
339
+ }
340
+ }
341
+ }
342
+
343
+ if (pathPass) {
344
+ const hashValue = hash(content + relativePath)
345
+ newCache.files[relativePath] = hashValue
346
+
347
+ for (let dir = path.dirname(relativePath); dir && dir !== '.'; dir = path.dirname(dir)) {
348
+ newDirs.add(dir)
349
+ }
350
+
351
+ if (oldCache.files[relativePath] === hashValue) {
352
+ return
353
+ }
354
+
355
+ const packTypeDir = relativePath.split(/[/\\]/)[0]
356
+ changedPackTypes.add(packTypeDir)
357
+
358
+ const realPath = path.join(outputFolder, relativePath)
359
+ await fs.ensureDir(path.dirname(realPath))
360
+ return await fs.writeFile(realPath, content)
361
+ }
362
+ }),
363
+ })
364
+
365
+ // Process and export packs
366
+ const packTypesArray = [...packTypes]
367
+
368
+ if (!cliOptions.production) {
369
+ // Auto-detect client path if needed for client-side packs
370
+ const hasClientPacks = packTypesArray.some(([, pt]) => pt.networkSides === 'client')
371
+ if (hasClientPacks && !clientPath && (root || worldName)) {
372
+ clientPath = await getClientPath()
373
+ }
374
+
375
+ const clientOnlyExport = !worldName && !root
376
+
377
+ for (const [, packType] of packTypesArray) {
378
+ const outputPath = path.join(outputFolder, packType.type)
379
+
380
+ // Process pack type output (post-processing generated files)
381
+ await processPackTypeOutput(packType, outputPath)
382
+ await processExternalResources(packType.type, folder, outputFolder, oldCache, newCache, changedPackTypes, newDirs, fileExclusions, fileHandlers)
383
+
384
+ // Determine export destinations
385
+ const shouldExportToClient = clientPath && !(clientOnlyExport && packType.networkSides !== 'client')
386
+ const shouldExportToServer = serverPath && packType.networkSides === 'server'
387
+
388
+ const clientDest = shouldExportToClient
389
+ ? getExportPath(packType, clientPath!, 'client', packName, worldName, saveOptions.exportZips)
390
+ : undefined
391
+ const serverDest = shouldExportToServer
392
+ ? getExportPath(packType, serverPath!, 'server', packName, worldName, saveOptions.exportZips)
393
+ : undefined
394
+
395
+ // Preserve existing symlinks (even if no files changed)
396
+ preserveSymlink(clientDest, oldCache, newCache)
397
+ preserveSymlink(serverDest, oldCache, newCache)
398
+
399
+ // Skip actual export if nothing changed
400
+ if (!changedPackTypes.has(packType.type)) continue
401
+
402
+ // Archive if configured
403
+ let archivedOutput = false
404
+ if (packType.archiveOutput && saveOptions.exportZips) {
405
+ archivedOutput = await createArchive(outputFolder, packName, packType, newCache)
406
+ }
407
+
408
+ // Export to destinations
409
+ if (clientDest) {
410
+ await exportPack(clientDest, clientPath!, outputPath, outputFolder, folder, packName, packType, archivedOutput, saveOptions.exportZips, oldCache, newCache)
411
+ await runExportHandler(packType, 'client', clientDest)
412
+ }
413
+ if (serverDest) {
414
+ await exportPack(serverDest, serverPath!, outputPath, outputFolder, folder, packName, packType, archivedOutput, saveOptions.exportZips, oldCache, newCache)
415
+ await runExportHandler(packType, 'server', serverDest)
416
+ }
417
+ }
418
+ } else {
419
+ // Production mode: just process, no exports
420
+ for (const [, packType] of packTypesArray) {
421
+ const outputPath = path.join(outputFolder, packType.type)
422
+ await processPackTypeOutput(packType, outputPath)
423
+ await processExternalResources(packType.type, folder, outputFolder, oldCache, newCache, changedPackTypes, newDirs, fileExclusions, fileHandlers)
424
+ }
425
+ }
426
+
427
+ // Clean up old files and directories
428
+ if (cliOptions.dry !== true) {
429
+ const deletedDirs = new Set<string>()
430
+
431
+ for (const file of Object.keys(oldCache.files)) {
432
+ if (!(file in newCache.files)) {
433
+ // Skip files whose parent directory was already deleted
434
+ const fileDir = path.dirname(file)
435
+ if (deletedDirs.has(fileDir)) continue
436
+ let skipFile = false
437
+ for (const deletedDir of deletedDirs) {
438
+ if (fileDir.startsWith(deletedDir + path.sep)) {
439
+ skipFile = true
440
+ break
441
+ }
442
+ }
443
+ if (skipFile) continue
444
+
445
+ try {
446
+ await fs.rm(path.join(outputFolder, file))
447
+ } catch (e: any) {
448
+ if (e.code !== 'ENOENT') throw e
449
+ log(chalk.yellow('Warning:'), `Cached file not found during cleanup: ${file}`)
450
+ }
451
+
452
+ let dir: string | undefined = undefined
453
+ for (const segment of split(new RegExp(RegExp.escape(path.sep)), fileDir)) {
454
+ dir = dir === undefined ? segment : path.join(dir, segment)
455
+
456
+ if (!newDirs.has(dir)) {
457
+ await fs.rm(path.join(outputFolder, dir), { force: true, recursive: true })
458
+ deletedDirs.add(dir)
459
+ break
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ await cleanupOldArchives(outputFolder, oldCache, newCache)
466
+ await cleanupOldSymlinks(oldCache, newCache)
467
+
468
+ await saveCache(cacheFile, newCache)
469
+ }
470
+
471
+ // Run afterAll script
472
+ await scripts?.afterAll?.()
473
+
474
+ // Count resources
475
+ const resourceCounts = countResources(sandstonePack)
476
+
477
+ const exports = [clientPath && 'client', serverPath && 'server'].filter(Boolean).join(' & ') || false
478
+ const countMsg = `${resourceCounts.functions} functions, ${resourceCounts.other} other resources`
479
+ if (!silent) {
480
+ log(`Pack(s) compiled! (${countMsg})${exports ? ` Exported to ${exports}.` : ''}`)
481
+ }
482
+
483
+ return { resourceCounts, sandstoneConfig, sandstonePack, resetSandstonePack }
484
+ }
485
+
486
+ export async function _buildCommand(
487
+ opts: BuildOptions,
488
+ _folder?: string,
489
+ existingContext?: BuildContext,
490
+ watching = false
491
+ ): Promise<BuildResult> {
492
+ const folder = _folder ?? opts.path
493
+
494
+ try {
495
+ const result = await _buildProject(opts, folder, true, existingContext, watching)
496
+ return {
497
+ success: true,
498
+ resourceCounts: result?.resourceCounts ?? { functions: 0, other: 0 },
499
+ timestamp: Date.now(),
500
+ sandstoneConfig: result?.sandstoneConfig,
501
+ sandstonePack: result?.sandstonePack,
502
+ resetSandstonePack: result?.resetSandstonePack,
503
+ }
504
+ } catch (err: any) {
505
+ const errorMessage = err.message || String(err)
506
+ const stack = (err.stack as string) || ''
507
+ const cleanedStack = stack
508
+ .replace(/\?hot-hook=\d+/g, '')
509
+ .replace(/file:\/\/\//g, '')
510
+ .replace(/file:\/\//g, '')
511
+ // Stack includes message at top - extract only the trace lines to avoid duplication
512
+ const stackLines = cleanedStack.split('\n')
513
+ const traceStart = stackLines.findIndex(line => line.trimStart().startsWith('at '))
514
+ const stackTrace = traceStart >= 0 ? stackLines.slice(traceStart).join('\n') : ''
515
+ const formattedError = stackTrace ? `${errorMessage}\n${stackTrace}` : errorMessage
516
+ return {
517
+ success: false,
518
+ error: formattedError,
519
+ resourceCounts: { functions: 0, other: 0 },
520
+ timestamp: Date.now(),
521
+ }
522
+ }
523
+ }
524
+
525
+ export async function buildCommand(opts: BuildOptions, _?: string): Promise<void>
526
+ export async function buildCommand(opts: BuildOptions, _folder: string | undefined, silent: true): Promise<BuildResult>
527
+ export async function buildCommand(opts: BuildOptions, _folder?: string, silent = false): Promise<BuildResult | void> {
528
+ const folder = (typeof _folder === 'string') ? _folder : opts.path
529
+
530
+ initLoggerNoFile()
531
+ setSilent(silent)
532
+
533
+ try {
534
+ const result = await _buildProject(opts, folder, silent)
535
+ if (silent) {
536
+ return {
537
+ success: true,
538
+ resourceCounts: result?.resourceCounts ?? { functions: 0, other: 0 },
539
+ timestamp: Date.now(),
540
+ sandstoneConfig: result?.sandstoneConfig,
541
+ sandstonePack: result?.sandstonePack,
542
+ resetSandstonePack: result?.resetSandstonePack,
543
+ }
544
+ }
545
+ } catch (err: any) {
546
+ const errorMessage = err.message || String(err)
547
+ const stack = (err.stack as string) || ''
548
+ const cleanedStack = stack
549
+ .replace(/\?hot-hook=\d+/g, '')
550
+ .replace(/file:\/\/\//g, '')
551
+ .replace(/file:\/\//g, '')
552
+ // Stack includes message at top - extract only the trace lines to avoid duplication
553
+ const stackLines = cleanedStack.split('\n')
554
+ const traceStart = stackLines.findIndex(line => line.trimStart().startsWith('at '))
555
+ const stackTrace = traceStart >= 0 ? stackLines.slice(traceStart).join('\n') : ''
556
+ const formattedError = stackTrace ? `${errorMessage}\n${stackTrace}` : errorMessage
557
+ if (!silent) {
558
+ log(chalk.bgRed.white('BuildError') + chalk.gray(':'), formattedError)
559
+ process.exit(1)
560
+ }
561
+ return {
562
+ success: false,
563
+ error: formattedError,
564
+ resourceCounts: { functions: 0, other: 0 },
565
+ timestamp: Date.now(),
566
+ }
567
+ }
568
+ }
@@ -130,7 +130,7 @@ export async function createCommand(_project: string, opts: CreateOptions) {
130
130
 
131
131
  const sv = (v: string) => new SemVer(v)
132
132
 
133
- const versions = [[sv('1.0.0-beta.1'), sv(CLI_VERSION)]] as const
133
+ const versions = [[sv('1.0.0-beta.3'), sv(CLI_VERSION)]] as const
134
134
 
135
135
  const version = await select({
136
136
  message: 'Which version of Sandstone do you want to use? These are the only supported versions for new projects.',
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { exec } from 'child_process'
4
- import { buildCommand } from './build.js'
4
+ import { buildCommand } from './build/index.js'
5
5
  import { checkbox } from '@inquirer/prompts'
6
6
 
7
7
  const _fetch = import('node-fetch')
@@ -1,4 +1,4 @@
1
- export { buildCommand } from './build.js'
1
+ export { buildCommand } from './build/index.js'
2
2
  export { createCommand } from './create.js'
3
3
  export { installNativeCommand, installVanillaCommand, uninstallVanillaCommand, refreshCommand } from './dependency.js'
4
4
  export { watchCommand, type WatchOptions } from './watch.js'
@@ -4,14 +4,49 @@ import React from 'react'
4
4
  import { render } from 'ink'
5
5
 
6
6
  import { normalizePath } from '../utils.js'
7
- import { _buildCommand, type BuildOptions, type BuildContext, enableConsoleCapture, disableConsoleCapture } from './build.js'
7
+ import { _buildCommand, type BuildOptions, type BuildContext } from './build/index.js'
8
8
  import { WatchUI, getWatchUIAPI } from '../ui/WatchUI.js'
9
- import { initLogger, log, logError, setLiveLogCallback } from '../ui/logger.js'
9
+ import { initLogger, log, logInfo, logWarn, logError, logDebug, logTrace, setLiveLogCallback } from '../ui/logger.js'
10
10
  import type { TrackedChange, ChangeCategory } from '../ui/types.js'
11
11
  import { hot } from '@sandstone-mc/hot-hook'
12
12
  import fs from 'fs-extra'
13
13
  import { join, relative } from 'node:path'
14
14
 
15
+ // Console capture for watch mode - wraps console to redirect output to our log file
16
+ const originalConsole = globalThis.console
17
+ let consoleWrapped = false
18
+
19
+ function enableConsoleCapture() {
20
+ if (consoleWrapped) return
21
+ consoleWrapped = true
22
+
23
+ ;(globalThis.console as any).log = (...args: any[]) => log(...args)
24
+ ;(globalThis.console as any).info = (...args: any[]) => logInfo(...args)
25
+ ;(globalThis.console as any).warn = (...args: any[]) => logWarn(...args)
26
+ ;(globalThis.console as any).error = (...args: any[]) => logError(args.join(' '))
27
+ ;(globalThis.console as any).debug = (...args: any[]) => logDebug(...args)
28
+
29
+ ;(globalThis.console as any).trace = (...args: any[]) => {
30
+ const traceObj = { stack: '' }
31
+ Error.captureStackTrace(traceObj, globalThis.console.trace)
32
+ const cleanedStack = traceObj.stack
33
+ .replace(/^Error\n/, '')
34
+ .replace(/\?hot-hook=\d+/g, '')
35
+ .replace(/file:\/\/\/?/g, '')
36
+ logTrace(...args, '\n' + cleanedStack)
37
+ }
38
+ }
39
+
40
+ function disableConsoleCapture() {
41
+ if (!consoleWrapped) return
42
+ consoleWrapped = false
43
+
44
+ const methodsToRestore = ['log', 'info', 'warn', 'error', 'debug', 'trace'] as const
45
+ for (const method of methodsToRestore) {
46
+ ;(globalThis.console as any)[method] = originalConsole[method].bind(originalConsole)
47
+ }
48
+ }
49
+
15
50
  export interface WatchOptions extends BuildOptions {
16
51
  manual?: boolean
17
52
  library?: boolean
@@ -182,9 +182,17 @@ export function WatchUI({ manual, onManualRebuild, exit }: WatchUIProps) {
182
182
 
183
183
  const maxScroll = getMaxScroll()
184
184
  if (key.upArrow) {
185
- setScrollOffset(prev => Math.min(maxScroll, prev + 1))
185
+ if (isError) {
186
+ setScrollOffset(prev => Math.max(0, prev - 1))
187
+ } else {
188
+ setScrollOffset(prev => Math.min(maxScroll, prev + 1))
189
+ }
186
190
  } else if (key.downArrow) {
187
- setScrollOffset(prev => Math.max(0, prev - 1))
191
+ if (isError) {
192
+ setScrollOffset(prev => Math.min(maxScroll, prev + 1))
193
+ } else {
194
+ setScrollOffset(prev => Math.max(0, prev - 1))
195
+ }
188
196
  }
189
197
 
190
198
  if (manual && status === 'pending') {
package/src/utils.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import os from 'os'
4
+ import crypto from 'crypto'
4
5
  import { execSync } from 'child_process'
5
6
  import chalk from 'chalk-template'
6
7
 
8
+ /** Hash a string or buffer using MD5 */
9
+ export function hash(data: string | Buffer): string {
10
+ return crypto.createHash('md5').update(data).digest('hex')
11
+ }
12
+
7
13
  /** Normalize a path to use forward slashes */
8
14
  export const normalizePath = (p: string) => p.replaceAll('\\', '/')
9
15
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '2.2.1'
1
+ export const CLI_VERSION = '2.3.1'