sandstone-cli 2.2.0 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandstone-cli",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "The CLI for Sandstone - the minecraft pack creation library.",
5
5
  "type": "module",
6
6
  "exports": "./lib/index.js",
@@ -45,7 +45,7 @@
45
45
  "dependencies": {
46
46
  "@inquirer/prompts": "^8.2.0",
47
47
  "@parcel/watcher": "^2.5.6",
48
- "@sandstone-mc/hot-hook": "link:@sandstone-mc/hot-hook",
48
+ "@sandstone-mc/hot-hook": "^0.1.0",
49
49
  "@types/chalk": "^2.2.4",
50
50
  "adm-zip": "^0.5.10",
51
51
  "chalk": "^5.6.2",
@@ -69,7 +69,10 @@
69
69
  "@types/semver": "^7.5.3",
70
70
  "bun-types": "^1.3.9",
71
71
  "node-pty": "^1.1.0",
72
- "sandstone": "link:sandstone",
72
+ "sandstone": "^1.0.0-beta.2",
73
73
  "typescript": "^5.2.2"
74
- }
74
+ },
75
+ "trustedDependencies": [
76
+ "@parcel/watcher"
77
+ ]
75
78
  }
@@ -0,0 +1,295 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import fs from 'fs-extra'
4
+ import AdmZip from 'adm-zip'
5
+
6
+ import { log } from '../../ui/logger.js'
7
+ import { canUseSymlinks } from '../../utils.js'
8
+
9
+ import type { handlerReadFile, PackType } from 'sandstone/pack'
10
+
11
+ export type SandstoneCache = {
12
+ files: Record<string, string>
13
+ archives?: string[]
14
+ canUseSymlinks?: boolean
15
+ symlinks?: string[]
16
+ }
17
+
18
+ // Module-level symlink availability cache
19
+ let symlinksAvailable: boolean | undefined
20
+
21
+ export async function checkSymlinksAvailable(cachedValue?: boolean): Promise<boolean> {
22
+ if (symlinksAvailable === undefined) {
23
+ if (cachedValue !== undefined) {
24
+ symlinksAvailable = cachedValue
25
+ } else {
26
+ symlinksAvailable = await canUseSymlinks()
27
+ }
28
+ }
29
+ return symlinksAvailable
30
+ }
31
+
32
+ export function getSymlinksAvailable(): boolean {
33
+ return symlinksAvailable ?? false
34
+ }
35
+
36
+ // Minecraft path detection
37
+
38
+ function getMCPath(): string {
39
+ switch (os.platform()) {
40
+ case 'win32':
41
+ return path.join(os.homedir(), 'AppData/Roaming/.minecraft')
42
+ case 'darwin':
43
+ return path.join(os.homedir(), 'Library/Application Support/minecraft')
44
+ case 'linux':
45
+ default:
46
+ return path.join(os.homedir(), '.minecraft')
47
+ }
48
+ }
49
+
50
+ export async function getClientPath(): Promise<string | undefined> {
51
+ const mcPath = getMCPath()
52
+
53
+ try {
54
+ await fs.stat(mcPath)
55
+ } catch {
56
+ log('Unable to locate the .minecraft folder. Will not be able to export to client.')
57
+ return undefined
58
+ }
59
+
60
+ return mcPath
61
+ }
62
+
63
+ export async function getClientWorldPath(worldName: string, minecraftPath?: string): Promise<string> {
64
+ const mcPath = minecraftPath ?? (await getClientPath())!
65
+ const savesPath = path.join(mcPath, 'saves')
66
+ const worldPath = path.join(savesPath, worldName)
67
+
68
+ if (!fs.existsSync(worldPath)) {
69
+ const existingWorlds = (await fs.readdir(savesPath, { withFileTypes: true }))
70
+ .filter((f) => f.isDirectory())
71
+ .map((f) => f.name)
72
+
73
+ throw new Error(
74
+ `Unable to locate the "${worldPath}" folder. World ${worldName} does not exist. List of existing worlds: ${JSON.stringify(existingWorlds, null, 2)}`,
75
+ )
76
+ }
77
+
78
+ return worldPath
79
+ }
80
+
81
+ // Symlink handling
82
+
83
+ export async function createSymlink(
84
+ folder: string,
85
+ packName: string,
86
+ newCache: SandstoneCache,
87
+ minecraftPath: string,
88
+ targetPath: string,
89
+ linkPath: string
90
+ ) {
91
+ // Update allowed_symlinks.txt for Minecraft
92
+ let rawPath = path.resolve(path.join(folder))
93
+ let sep: string = path.sep
94
+ if (os.platform() === 'win32') {
95
+ sep = `${path.sep}${path.sep}`
96
+ rawPath = rawPath.replace(path.sep, sep)
97
+ }
98
+ const allowPath = `[glob]${rawPath}${sep}**${sep}*`
99
+
100
+ const allowedList = path.join(minecraftPath, 'allowed_symlinks.txt')
101
+
102
+ const comment = `# Sandstone Pack: ${packName}\n`
103
+ try {
104
+ const currentlyAllowed = await fs.readFile(allowedList, 'utf-8')
105
+
106
+ if (!currentlyAllowed.includes(allowPath)) {
107
+ log('[symlink] Adding workspace to allowed_symlinks.txt. If the game is running please restart it.')
108
+ await fs.writeFile(allowedList, `${currentlyAllowed}\n#\n${comment}${allowPath}`)
109
+ } else {
110
+ log('[symlink] Workspace already in allowed_symlinks.txt, skipping...')
111
+ }
112
+ } catch (e) {
113
+ log('[symlink] Creating allowed_symlinks.txt. If the game is running please restart it.')
114
+ await fs.writeFile(allowedList, `${comment}${allowPath}`)
115
+ }
116
+
117
+ // Check if symlink already exists
118
+ let skip = false
119
+ let errored = false
120
+ try {
121
+ const stats = await fs.lstat(linkPath)
122
+ if (stats.isSymbolicLink() && await fs.readlink(linkPath) === path.resolve(targetPath)) {
123
+ log('[symlink] Symlink already created, skipping...')
124
+ skip = true
125
+ } else {
126
+ errored = true
127
+ }
128
+ } catch {}
129
+
130
+ if (errored) {
131
+ throw new Error(`Tried to add a symlink at "${linkPath}",\n encountered an existing FS entry.`)
132
+ }
133
+
134
+ // Create symlink
135
+ if (!skip) {
136
+ log(`[symlink] Creating symlink for ${targetPath.replace(`${path.dirname(targetPath)}${path.sep}`, '')}`)
137
+ await fs.symlink(path.resolve(targetPath), linkPath)
138
+ }
139
+
140
+ // Track in cache
141
+ newCache.symlinks ??= []
142
+ newCache.symlinks.push(linkPath)
143
+ }
144
+
145
+ // Archive creation
146
+
147
+ export async function createArchive(
148
+ outputFolder: string,
149
+ packName: string,
150
+ packType: PackType,
151
+ newCache: SandstoneCache
152
+ ): Promise<boolean> {
153
+ const input = path.join(outputFolder, packType.type)
154
+
155
+ const files = await fs.readdir(input).catch(() => [])
156
+ if (files.length === 0) return false
157
+
158
+ const archiveName = `${packName}_${packType.type}.zip`
159
+ newCache.archives ??= []
160
+ newCache.archives.push(archiveName)
161
+
162
+ const archive = new AdmZip()
163
+ await archive.addLocalFolderPromise(input, {})
164
+ await fs.ensureDir(path.join(outputFolder, 'archives'))
165
+ await archive.writeZipPromise(
166
+ path.join(outputFolder, 'archives', archiveName),
167
+ { overwrite: true },
168
+ )
169
+
170
+ return true
171
+ }
172
+
173
+ // Run pack type's export handler for client/server destinations
174
+
175
+ export async function runExportHandler(
176
+ packType: PackType,
177
+ target: 'client' | 'server',
178
+ exportPath: string
179
+ ) {
180
+ if (!packType.handleOutput) return
181
+
182
+ await packType.handleOutput(
183
+ target,
184
+ (async (relativePath: string, encoding: BufferEncoding = 'utf8') =>
185
+ await fs.readFile(path.join(exportPath, relativePath), encoding)) as unknown as handlerReadFile,
186
+ async (relativePath: string, contents: any) => {
187
+ if (contents === undefined) {
188
+ await fs.unlink(path.join(exportPath, relativePath))
189
+ } else {
190
+ await fs.writeFile(path.join(exportPath, relativePath), contents)
191
+ }
192
+ },
193
+ )
194
+ }
195
+
196
+ // Export destination helpers
197
+
198
+ export function preserveSymlink(
199
+ symlinkPath: string | undefined,
200
+ oldCache: SandstoneCache,
201
+ newCache: SandstoneCache
202
+ ) {
203
+ if (!getSymlinksAvailable() || !symlinkPath) return
204
+ if (!oldCache.symlinks?.includes(symlinkPath)) return
205
+
206
+ newCache.symlinks ??= []
207
+ if (!newCache.symlinks.includes(symlinkPath)) {
208
+ newCache.symlinks.push(symlinkPath)
209
+ }
210
+ }
211
+
212
+ export async function exportPack(
213
+ destPath: string,
214
+ minecraftPath: string,
215
+ outputPath: string,
216
+ outputFolder: string,
217
+ folder: string,
218
+ packName: string,
219
+ packType: PackType,
220
+ archivedOutput: boolean,
221
+ exportZips: boolean | undefined,
222
+ oldCache: SandstoneCache,
223
+ newCache: SandstoneCache
224
+ ) {
225
+ if (packType.archiveOutput && archivedOutput && exportZips) {
226
+ // Copy archive
227
+ const archivePath = path.join(outputFolder, 'archives', `${packName}_${packType.type}.zip`)
228
+ await fs.copyFile(archivePath, `${destPath}.zip`)
229
+ } else if (getSymlinksAvailable()) {
230
+ // Create symlink (only if it doesn't already exist)
231
+ if (!oldCache.symlinks?.includes(destPath)) {
232
+ await createSymlink(folder, packName, newCache, minecraftPath, outputPath, destPath)
233
+ }
234
+ } else {
235
+ // Copy files
236
+ await fs.remove(destPath)
237
+ await fs.copy(outputPath, destPath)
238
+ }
239
+ }
240
+
241
+ export function getExportPath(
242
+ packType: PackType,
243
+ basePath: string,
244
+ target: 'client' | 'server',
245
+ packName: string,
246
+ worldName: string | undefined,
247
+ exportZips: boolean | undefined
248
+ ): string {
249
+ if (target === 'server') {
250
+ return path.join(basePath, packType.serverPath).replace('$packName$', packName)
251
+ }
252
+
253
+ // Client path: use world path or root path
254
+ const useWorldPath = worldName && (packType.type !== 'resourcepack' || exportZips)
255
+ if (useWorldPath) {
256
+ return path.join(basePath, packType.clientPath)
257
+ .replace('$packName$', packName)
258
+ .replace('$worldName$', worldName)
259
+ }
260
+ return path.join(basePath, packType.rootPath).replace('$packName$', packName)
261
+ }
262
+
263
+ // Cleanup
264
+
265
+ export async function cleanupOldSymlinks(oldCache: SandstoneCache, newCache: SandstoneCache) {
266
+ if (!oldCache.symlinks) return
267
+
268
+ const newSymlinks = new Set(newCache.symlinks)
269
+
270
+ for (const symlink of oldCache.symlinks) {
271
+ if (!newSymlinks.has(symlink)) {
272
+ await fs.unlink(symlink)
273
+ }
274
+ }
275
+ }
276
+
277
+ export async function cleanupOldArchives(
278
+ outputFolder: string,
279
+ oldCache: SandstoneCache,
280
+ newCache: SandstoneCache
281
+ ) {
282
+ if (!oldCache.archives) return
283
+
284
+ const archivesDir = path.join(outputFolder, 'archives')
285
+ if (!newCache.archives || newCache.archives.length === 0) {
286
+ await fs.rm(archivesDir, { force: true, recursive: true })
287
+ return
288
+ }
289
+
290
+ for (const archive of oldCache.archives) {
291
+ if (!newCache.archives.includes(archive)) {
292
+ await fs.rm(path.join(archivesDir, archive))
293
+ }
294
+ }
295
+ }
@@ -0,0 +1,122 @@
1
+ import path from 'node:path'
2
+ import fs from 'fs-extra'
3
+
4
+ import type { SandstoneCache } from './export.js'
5
+ import { hash } from '../../utils.js'
6
+
7
+ export type FileExclusions = {
8
+ generated: RegExp[] | undefined
9
+ existing: RegExp[] | undefined
10
+ } | false
11
+
12
+ export type FileHandler = {
13
+ path: RegExp
14
+ callback: (contents: string | Buffer | Promise<Buffer>) => Promise<Buffer>
15
+ }
16
+
17
+ async function walk(dir: string): Promise<string[]> {
18
+ const files: string[] = []
19
+ const entries = await fs.readdir(dir, { withFileTypes: true })
20
+ for (const entry of entries) {
21
+ const fullPath = path.join(dir, entry.name)
22
+ if (entry.isDirectory()) {
23
+ files.push(...(await walk(fullPath)))
24
+ } else {
25
+ files.push(fullPath)
26
+ }
27
+ }
28
+ return files
29
+ }
30
+
31
+ /**
32
+ * Check if external resources exist and register pack types accordingly.
33
+ */
34
+ export async function autoRegisterPackTypes(
35
+ folder: string,
36
+ sandstonePack: { resourcePack: () => void; dataPack: () => void }
37
+ ) {
38
+ const resourcesFolder = path.join(folder, 'resources')
39
+
40
+ if (await fs.pathExists(path.join(resourcesFolder, 'resourcepack'))) {
41
+ const files = await fs.readdir(path.join(resourcesFolder, 'resourcepack'))
42
+ if (files.length > 0) {
43
+ sandstonePack.resourcePack()
44
+ }
45
+ }
46
+
47
+ if (await fs.pathExists(path.join(resourcesFolder, 'datapack'))) {
48
+ const files = await fs.readdir(path.join(resourcesFolder, 'datapack'))
49
+ if (files.length > 0) {
50
+ sandstonePack.dataPack()
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Process external resources from the resources/ folder.
57
+ */
58
+ export async function processExternalResources(
59
+ packType: string,
60
+ folder: string,
61
+ outputFolder: string,
62
+ oldCache: SandstoneCache,
63
+ newCache: SandstoneCache,
64
+ changedPackTypes: Set<string>,
65
+ newDirs: Set<string>,
66
+ fileExclusions: FileExclusions,
67
+ fileHandlers: FileHandler[] | false
68
+ ) {
69
+ const working = path.join(folder, 'resources', packType)
70
+
71
+ if (!(await fs.pathExists(working))) {
72
+ return
73
+ }
74
+
75
+ for (const file of await walk(working)) {
76
+ const relativePath = path.join(packType, file.substring(working.length + 1))
77
+
78
+ // Check exclusions
79
+ let pathPass = true
80
+ if (fileExclusions && fileExclusions.existing) {
81
+ for (const exclude of fileExclusions.existing) {
82
+ pathPass = Array.isArray(exclude) ? !exclude[0].test(relativePath) : !exclude.test(relativePath)
83
+ }
84
+ }
85
+
86
+ if (!pathPass) continue
87
+
88
+ try {
89
+ let content = await fs.readFile(file)
90
+
91
+ // Apply file handlers
92
+ if (fileHandlers) {
93
+ for (const handler of fileHandlers) {
94
+ if (handler.path.test(relativePath)) {
95
+ content = (await handler.callback(content)) as Buffer<ArrayBuffer>
96
+ }
97
+ }
98
+ }
99
+
100
+ const hashValue = hash(content + relativePath)
101
+ newCache.files[relativePath] = hashValue
102
+
103
+ // Track directories
104
+ for (let dir = path.dirname(relativePath); dir && dir !== '.'; dir = path.dirname(dir)) {
105
+ if (newDirs.has(dir)) {
106
+ break
107
+ } else {
108
+ newDirs.add(dir)
109
+ }
110
+ }
111
+
112
+ // Write if changed
113
+ if (oldCache.files[relativePath] !== hashValue) {
114
+ changedPackTypes.add(packType)
115
+
116
+ const realPath = path.join(outputFolder, relativePath)
117
+ await fs.ensureDir(path.dirname(realPath))
118
+ await fs.writeFile(realPath, content)
119
+ }
120
+ } catch {}
121
+ }
122
+ }