sandstone-cli 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ import path from 'path'
2
+ import * as os from 'os'
3
+ import crypto from 'crypto'
4
+ import fs from 'fs-extra'
5
+ import PrettyError from 'pretty-error'
6
+ import walk from 'klaw'
7
+
8
+ import chalk from 'chalk'
9
+ import AdmZip from 'adm-zip'
10
+ import deleteEmpty from 'delete-empty'
11
+ import { ProjectFolders } from '../utils.js'
12
+
13
+ type BuildOptions = {
14
+ // Flags
15
+ dry?: boolean
16
+ verbose?: boolean
17
+ root?: boolean
18
+ fullTrace?: boolean
19
+ strictErrors?: boolean
20
+ production?: boolean
21
+
22
+ // Values
23
+ path: string,
24
+ configPath: string,
25
+ name?: string
26
+ namespace?: string
27
+ world?: string
28
+ clientPath?: string
29
+ serverPath?: string
30
+
31
+ // TODO: implement ssh
32
+ ssh?: any
33
+ }
34
+
35
+ const pe = new PrettyError()
36
+
37
+ type SaveFileObject = {
38
+ relativePath: string
39
+ content: any
40
+ contentSummary: string
41
+ }
42
+
43
+ /*
44
+ * Sandstone files cache is just a key-value pair,
45
+ * key being the file path & value being the hash.
46
+ */
47
+ type SandstoneCache = Record<string, string>
48
+
49
+ // Return the hash of a string
50
+ function hash(stringToHash: string): string {
51
+ return crypto.createHash('md5').update(stringToHash).digest('hex')
52
+ }
53
+
54
+ // Recursively create a directory, without failing if it already exists
55
+ async function mkDir(dirPath: string) {
56
+ try {
57
+ await new Promise<void>((resolve, reject) => {
58
+ fs.mkdir(dirPath, { recursive: true }, (err) => {
59
+ if (err) reject(err)
60
+ resolve()
61
+ })
62
+ })
63
+ }
64
+ catch (error) {
65
+ // Directory already exists
66
+ }
67
+ }
68
+
69
+ let cache: SandstoneCache
70
+
71
+ /**
72
+ *
73
+ * @param worldName The name of the world
74
+ * @param minecraftPath The optional location of the .minecraft folder.
75
+ * If left unspecified, the .minecraft will be found automatically.
76
+ */
77
+ async function getClientWorldPath(worldName: string, minecraftPath: string | undefined = undefined) {
78
+ let mcPath: string
79
+
80
+ if (minecraftPath) {
81
+ mcPath = minecraftPath
82
+ } else {
83
+ mcPath = (await getClientPath())!
84
+ }
85
+
86
+ const savesPath = path.join(mcPath, 'saves')
87
+ const worldPath = path.join(savesPath, worldName)
88
+
89
+ if (!fs.existsSync(worldPath)) {
90
+ const existingWorlds = (await fs.readdir(savesPath, { withFileTypes: true })).filter((f: any) => f.isDirectory).map((f: {name: string}) => f.name) as string[]
91
+
92
+ throw new Error(`Unable to locate the "${worldPath}" folder. Word ${worldName} does not exists. List of existing worlds: ${JSON.stringify(existingWorlds, null, 2)}`)
93
+ }
94
+
95
+ return worldPath
96
+ }
97
+
98
+ /**
99
+ * Get the .minecraft path
100
+ */
101
+ async function getClientPath() {
102
+ function getMCPath(): string {
103
+ switch (os.platform()) {
104
+ case 'win32':
105
+ return path.join(os.homedir(), 'AppData/Roaming/.minecraft')
106
+ case 'darwin':
107
+ return path.join(os.homedir(), 'Library/Application Support/minecraft')
108
+ case 'linux':
109
+ default:
110
+ return path.join(os.homedir(), '.minecraft')
111
+ }
112
+ }
113
+
114
+ const mcPath = getMCPath()
115
+
116
+ try {
117
+ await fs.stat(mcPath)
118
+ } catch (e) {
119
+ console.warn('Unable to locate the .minecraft folder. Will not be able to export to client.')
120
+
121
+ return undefined
122
+ }
123
+
124
+ return mcPath
125
+ }
126
+
127
+ /**
128
+ * Build the project, but might throw errors.
129
+ *
130
+ * @param cliOptions The options to build the project with.
131
+ *
132
+ * @param projectFolder The folder of the project. It needs a sandstone.config.ts, and it or one of its parent needs a package.json.
133
+ */
134
+ async function _buildProject(cliOptions: BuildOptions, { absProjectFolder, rootFolder, sandstoneConfigFolder }: ProjectFolders) {
135
+
136
+ // First, read sandstone.config.ts to get all properties
137
+ const sandstoneConfig = (await import(path.join(sandstoneConfigFolder, 'sandstone.config.ts'))).default
138
+
139
+ const { scripts } = sandstoneConfig
140
+
141
+ let { saveOptions } = sandstoneConfig
142
+
143
+ if (saveOptions === undefined) saveOptions = {}
144
+
145
+ const outputFolder = path.join(rootFolder, '.sandstone', 'output')
146
+
147
+ /// OPTIONS ///
148
+ const clientPath = !cliOptions.production ? (cliOptions.clientPath || saveOptions.clientPath || await getClientPath()) : undefined
149
+ const server = !cliOptions.production && (cliOptions.serverPath || saveOptions.serverPath || cliOptions.ssh || saveOptions.ssh) ? await (async () => {
150
+ if (cliOptions.ssh || saveOptions.ssh) {
151
+ const sshOptions = JSON.stringify(await fs.readFile(cliOptions.ssh || saveOptions.ssh, 'utf8'))
152
+
153
+ // TODO: implement SFTP
154
+ return {
155
+ readFile: async (relativePath: string, encoding: fs.EncodingOption = 'utf8') => {},
156
+ writeFile: async (relativePath: string, contents: any) => {},
157
+ remove: async (relativePath: string) => {},
158
+ }
159
+ }
160
+ const serverPath = cliOptions.serverPath || saveOptions.serverPath
161
+ return {
162
+ readFile: async (relativePath: string, encoding: fs.EncodingOption = 'utf8') => await fs.readFile(path.join(serverPath, relativePath), encoding),
163
+ writeFile: async (relativePath: string, contents: any) => {
164
+ if (contents === undefined) {
165
+ await fs.unlink(path.join(serverPath, relativePath))
166
+ } else {
167
+ await fs.writeFile(path.join(serverPath, relativePath), contents)
168
+ }
169
+ },
170
+ remove: async (relativePath: string) => await fs.remove(path.join(serverPath, relativePath))
171
+ }
172
+ })() : undefined
173
+ let worldName: undefined | string = cliOptions.world || saveOptions.world
174
+ // Make sure the world exists
175
+ if (worldName && !cliOptions.production) {
176
+ await getClientWorldPath(worldName, clientPath)
177
+ }
178
+ const root = cliOptions.root !== undefined ? cliOptions.root : saveOptions.root
179
+
180
+ const packName: string = cliOptions.name ?? sandstoneConfig.name
181
+
182
+ if (worldName && root) {
183
+ throw new Error(`Expected only 'world' or 'root'. Got both.`)
184
+ }
185
+
186
+ // Important /!\: The below if statements, which set environment variables, must run before importing any Sandstone file.
187
+
188
+ // Set the pack ID environment variable
189
+
190
+ // Set production/development mode
191
+ if (cliOptions.production) {
192
+ process.env.SANDSTONE_ENV = 'production'
193
+ } else {
194
+ process.env.SANDSTONE_ENV = 'development'
195
+ }
196
+
197
+ process.env.WORKING_DIR = absProjectFolder
198
+
199
+ if (sandstoneConfig.packUid) {
200
+ process.env.PACK_UID = sandstoneConfig.packUid
201
+ }
202
+
203
+ // Set the namespace
204
+ const namespace = cliOptions.namespace || sandstoneConfig.namespace
205
+ if (namespace) {
206
+ process.env.NAMESPACE = namespace
207
+ }
208
+
209
+ for (const [k, pack] of Object.entries(sandstoneConfig.packs) as any[]) {
210
+ if (pack.onConflict) {
211
+ for (const resource of Object.entries(pack.onConflict)) {
212
+ process.env[`${resource[0].toUpperCase()}_CONFLICT_STRATEGY`] = resource[1] as string
213
+ }
214
+ }
215
+ }
216
+
217
+ // JSON indentation
218
+ process.env.INDENTATION = saveOptions.indentation
219
+
220
+ // Pack mcmeta
221
+ process.env.PACK_OPTIONS = JSON.stringify(sandstoneConfig.packs)
222
+
223
+ // Configure error display
224
+ if (!cliOptions.fullTrace) {
225
+ pe.skipNodeFiles()
226
+ }
227
+
228
+ /// IMPORTING USER CODE ///
229
+ // The configuration is ready.
230
+
231
+ // Now, let's run the beforeAll script
232
+ await scripts?.beforeAll?.()
233
+
234
+ // Finally, let's import from the index.
235
+ let error = false
236
+
237
+ let sandstonePack: any
238
+
239
+ const filePath = path.join(absProjectFolder, 'index.ts')
240
+
241
+ try {
242
+ // Sometimes, a file might not exist because it has been deleted.
243
+ if (await fs.pathExists(filePath)) {
244
+ sandstonePack = (await import(filePath)).default
245
+ }
246
+ }
247
+ catch (e: any) {
248
+ logError(e, absProjectFolder)
249
+ error = true
250
+ }
251
+
252
+ if (error) {
253
+ return
254
+ }
255
+
256
+ /// SAVING RESULTS ///
257
+ // Setup the cache if it doesn't exist.
258
+ // This cache is here to avoid writing files on disk when they did not change.
259
+ const newCache: SandstoneCache = {}
260
+
261
+ const cacheFile = path.join(rootFolder, '.sandstone', 'cache.json')
262
+
263
+ if (cache === undefined) {
264
+ let oldCache: SandstoneCache | undefined
265
+ try {
266
+ const fileRead = await fs.readFile(cacheFile, 'utf8')
267
+ if (fileRead) {
268
+ oldCache = JSON.parse(fileRead)
269
+ }
270
+ } catch {}
271
+ if (oldCache) {
272
+ cache = oldCache
273
+ } else {
274
+ cache = {}
275
+ }
276
+ }
277
+
278
+ // Save the pack
279
+
280
+ // Run the beforeSave script (TODO: This is where sandstone-server will remove restart env vars)
281
+ await scripts?.beforeSave?.()
282
+
283
+ const excludeOption = saveOptions.resources?.exclude
284
+
285
+ const fileExclusions = excludeOption ? {
286
+ generated: (excludeOption.generated || excludeOption) as RegExp[] | undefined,
287
+ existing: (excludeOption.existing || excludeOption) as RegExp[] | undefined
288
+ } : false
289
+
290
+ const fileHandlers = saveOptions.resources?.handle as ({ path: RegExp, callback: (contents: string | Buffer | Promise<Buffer>) => Promise<Buffer> })[] || false
291
+
292
+ const packTypes = await sandstonePack.save({
293
+ // Additional parameters
294
+ dry: cliOptions.dry,
295
+ verbose: cliOptions.verbose,
296
+
297
+ fileHandler: saveOptions.customFileHandler ?? (async (relativePath: string, content: any) => {
298
+ let pathPass = true
299
+ if (fileExclusions && fileExclusions.generated) {
300
+ for (const exclude of fileExclusions.generated) {
301
+ if (!Array.isArray(exclude)) {
302
+ pathPass = !exclude.test(relativePath)
303
+ }
304
+ }
305
+ }
306
+
307
+ if (fileHandlers) {
308
+ for (const handler of fileHandlers) {
309
+ if (handler.path.test(relativePath)) {
310
+ content = await handler.callback(content)
311
+ }
312
+ }
313
+ }
314
+
315
+ if (pathPass) {
316
+ // We hash the relative path alongside the content to ensure unique hash.
317
+ const hashValue = hash(content + relativePath)
318
+
319
+ // Add to new cache.
320
+ newCache[relativePath] = hashValue
321
+
322
+ if (cache[relativePath] === hashValue) {
323
+ // Already in cache - skip
324
+ return
325
+ }
326
+
327
+ // Not in cache: write to disk
328
+ const realPath = path.join(outputFolder, relativePath)
329
+
330
+ await mkDir(path.dirname(realPath))
331
+ return await fs.writeFile(realPath, content)
332
+ }
333
+ })
334
+ })
335
+
336
+ async function handleResources(packType: string) {
337
+ const working = path.join(rootFolder, 'resources', packType)
338
+
339
+ let exists = false
340
+
341
+ try {
342
+ await fs.access(working)
343
+ exists = true
344
+ } catch (e) {}
345
+
346
+ if (exists) {
347
+ for await (const file of walk(path.join(rootFolder, 'resources', packType), { filter: (_path) => {
348
+ const relativePath = path.join(packType, _path.split(working)[1])
349
+ let pathPass = true
350
+ if (fileExclusions && fileExclusions.existing) {
351
+ for (const exclude of fileExclusions.existing) {
352
+ pathPass = Array.isArray(exclude) ? !exclude[0].test(relativePath) : !exclude.test(relativePath)
353
+ }
354
+ }
355
+ return pathPass
356
+ }})) {
357
+ const relativePath = path.join(packType, file.path.split(working)[1])
358
+
359
+ try {
360
+ let content = await fs.readFile(file.path)
361
+
362
+ if (fileHandlers) {
363
+ for (const handler of fileHandlers) {
364
+ if (handler.path.test(relativePath)) {
365
+ content = await handler.callback(content)
366
+ }
367
+ }
368
+ }
369
+
370
+ // We hash the relative path alongside the content to ensure unique hash.
371
+ const hashValue = hash(content + relativePath)
372
+
373
+ // Add to new cache.
374
+ newCache[relativePath] = hashValue
375
+
376
+ if (cache[relativePath] !== hashValue) {
377
+ // Not in cache: write to disk
378
+ const realPath = path.join(outputFolder, relativePath)
379
+
380
+ await mkDir(path.dirname(realPath))
381
+ await fs.writeFile(realPath, content)
382
+ }
383
+ } catch (e) {}
384
+ }
385
+ }
386
+ }
387
+
388
+ async function archiveOutput(packType: any) {
389
+ const input = path.join(outputFolder, packType.type)
390
+
391
+ if ((await fs.readdir(input)).length !== 0) {
392
+ const archive = new AdmZip()
393
+
394
+ await archive.addLocalFolderPromise(input, {})
395
+
396
+ await archive.writeZipPromise(`${path.join(outputFolder, 'archives', `${packName}_${packType.type}`)}.zip`, { overwrite: true })
397
+
398
+ return true
399
+ }
400
+
401
+ return false
402
+ }
403
+
404
+ // TODO: implement linking to make the cache more useful when not archiving.
405
+ if (!cliOptions.production) {
406
+ for await (const _packType of packTypes) {
407
+ const packType = _packType[1]
408
+ const outputPath = path.join(outputFolder, packType.type)
409
+
410
+ await fs.ensureDir(outputPath)
411
+
412
+ if (packType.handleOutput) {
413
+ await packType.handleOutput(
414
+ 'output',
415
+ async (relativePath: string, encoding: fs.EncodingOption = 'utf8') => await fs.readFile(path.join(outputPath, relativePath), encoding),
416
+ async (relativePath: string, contents: any) => {
417
+ if (contents === undefined) {
418
+ await fs.unlink(path.join(outputPath, relativePath))
419
+ } else {
420
+ await fs.writeFile(path.join(outputPath, relativePath), contents)
421
+ }
422
+ }
423
+ )
424
+ }
425
+
426
+ handleResources(packType.type)
427
+
428
+ let archivedOutput = false
429
+
430
+ if (packType.archiveOutput) {
431
+ archivedOutput = await archiveOutput(packType)
432
+ }
433
+
434
+ // Handle client
435
+ if (!(server && packType.networkSides === 'server') && clientPath) {
436
+ let fullClientPath: string
437
+
438
+ if (worldName) {
439
+ fullClientPath = path.join(clientPath, packType.clientPath)
440
+
441
+ try { fullClientPath = fullClientPath.replace('$packName$', packName) } catch {}
442
+ try { fullClientPath = fullClientPath.replace('$worldName$', worldName) } catch {}
443
+ } else {
444
+ fullClientPath = path.join(clientPath, packType.rootPath)
445
+
446
+ try { fullClientPath = fullClientPath.replace('$packName$', packName) } catch {}
447
+ }
448
+
449
+ if (packType.archiveOutput) {
450
+ if (archivedOutput) {
451
+ await fs.copyFile(`${path.join(outputFolder, 'archives', `${packName}_${packType.type}`)}.zip`, `${fullClientPath}.zip`)
452
+ }
453
+ } else {
454
+ await fs.remove(fullClientPath)
455
+
456
+ await fs.copy(outputPath, fullClientPath)
457
+ }
458
+
459
+ if (packType.handleOutput) {
460
+ await packType.handleOutput(
461
+ 'client',
462
+ async (relativePath: string, encoding: fs.EncodingOption = 'utf8') => await fs.readFile(path.join(clientPath, relativePath), encoding),
463
+ async (relativePath: string, contents: any) => {
464
+ if (contents === undefined) {
465
+ fs.unlink(path.join(clientPath, relativePath))
466
+ } else {
467
+ await fs.writeFile(path.join(clientPath, relativePath), contents)
468
+ }
469
+ }
470
+ )
471
+ }
472
+ }
473
+
474
+ // Handle server
475
+ if (server && (packType.networkSides === 'server' || packType.networkSides === 'both')) {
476
+ let serverPath: string = packType.serverPath
477
+
478
+ try { serverPath = serverPath.replace('$packName$', packName) } catch {}
479
+
480
+ if (packType.archiveOutput && archivedOutput) {
481
+ await server.writeFile(await fs.readFile(`${outputPath}.zip`, 'utf8'), `${serverPath}.zip`)
482
+ } else {
483
+ server.remove(serverPath)
484
+ for await (const file of walk(outputPath)) {
485
+ await server.writeFile(path.join(serverPath, file.path.split(outputPath)[1]), await fs.readFile(file.path))
486
+ }
487
+ }
488
+
489
+ if (packType.handleOutput) {
490
+ await packType.handleOutput('server', server.readFile, server.writeFile)
491
+ }
492
+ }
493
+ }
494
+ } else {
495
+ for await (const packType of packTypes) {
496
+ const outputPath = path.join(outputFolder, packType.type)
497
+
498
+ if (packType.handleOutput) {
499
+ await packType.handleOutput(
500
+ 'output',
501
+ async (relativePath: string, encoding: fs.EncodingOption = 'utf8') => await fs.readFile(path.join(outputPath, relativePath), encoding),
502
+ async (relativePath: string, contents: any) => {
503
+ if (contents === undefined) {
504
+ await fs.unlink(path.join(outputPath, relativePath))
505
+ } else {
506
+ await fs.writeFile(path.join(outputPath, relativePath), contents)
507
+ }
508
+ }
509
+ )
510
+ }
511
+
512
+ handleResources(packType.type)
513
+
514
+ if (packType.archiveOutput) {
515
+ archiveOutput(packType)
516
+ }
517
+ }
518
+ }
519
+
520
+ // Delete old files that aren't cached anymore
521
+ const oldFilesNames = new Set<string>(Object.keys(cache))
522
+
523
+ Object.keys(newCache).forEach(name => oldFilesNames.delete(name))
524
+
525
+ for await (const name of oldFilesNames) {
526
+ await fs.rm(path.join(outputFolder, name))
527
+ }
528
+
529
+ await deleteEmpty(outputFolder)
530
+
531
+
532
+ // Override old cache
533
+ cache = newCache
534
+
535
+ // Write the cache to disk
536
+ await fs.writeFile(cacheFile, JSON.stringify(cache))
537
+
538
+ // Run the afterAll script
539
+ await scripts?.afterAll?.()
540
+ }
541
+
542
+ /**
543
+ * Build the project. Will log errors and never throw any.
544
+ *
545
+ * @param options The options to build the project with.
546
+ *
547
+ * @param projectFolder The folder of the project. It needs a sandstone.config.ts, and it or one of its parent needs a package.json.
548
+ */
549
+ export async function buildProject(options: BuildOptions, folders: ProjectFolders) {
550
+ try {
551
+ await _buildProject(options, folders)
552
+ }
553
+ catch (err: any) {
554
+ console.log(err)
555
+ }
556
+ }
557
+
558
+ function logError(err?: Error, file?: string) {
559
+ if (err) {
560
+ if (file) {
561
+ console.error(
562
+ ' ' + chalk.bgRed.white('BuildError') + chalk.gray(':'),
563
+ `While loading "${file}", the following error happened:\n`
564
+ )
565
+ }
566
+ debugger
567
+ console.error(pe.render(err))
568
+ }
569
+ }
570
+
571
+ process.on('unhandledRejection', logError)
572
+ process.on('uncaughtException', logError)
@@ -0,0 +1,38 @@
1
+
2
+ import { register as tsEval } from 'ts-node'
3
+ import path from 'path'
4
+
5
+ import { getProjectFolders } from '../utils.js'
6
+ import { buildProject } from '../build/index.js'
7
+
8
+ type BuildOptions = {
9
+ // Flags
10
+ dry?: boolean
11
+ verbose?: boolean
12
+ root?: boolean
13
+ fullTrace?: boolean
14
+ strictErrors?: boolean
15
+ production?: boolean
16
+
17
+ // Values
18
+ path: string,
19
+ configPath: string,
20
+ name?: string
21
+ namespace?: string
22
+ world?: string
23
+ clientPath?: string
24
+ serverPath?: string
25
+
26
+ ssh?: any,
27
+ }
28
+
29
+ export async function buildCommand(opts: BuildOptions) {
30
+ const folders = getProjectFolders(opts.path)
31
+
32
+ tsEval({
33
+ transpileOnly: !opts.strictErrors,
34
+ project: path.join(folders.rootFolder, 'tsconfig.json'),
35
+ })
36
+
37
+ buildProject(opts, folders)
38
+ }