sandstone-cli 2.1.2 → 2.2.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/CLAUDE.md +107 -0
- package/bun.lock +8 -228
- package/lib/create.js +381 -9
- package/lib/index.js +421 -47
- package/package.json +7 -4
- package/scripts/test-harness.ts +341 -0
- package/src/commands/create.ts +102 -5
- package/src/launchers/index.ts +15 -0
- package/src/launchers/providers/modrinth.ts +134 -0
- package/src/launchers/providers/prism.ts +150 -0
- package/src/launchers/providers/vanilla.ts +48 -0
- package/src/launchers/registry.ts +46 -0
- package/src/launchers/types.ts +32 -0
- package/src/version.ts +1 -1
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Test harness for the Sandstone CLI.
|
|
4
|
+
* Uses node-pty to allow pre-programming responses for interactive prompts.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun test:harness create [project-name] --responses '<json>'
|
|
8
|
+
* bun test:harness sand <args...>
|
|
9
|
+
* bun test:harness cleanup
|
|
10
|
+
* bun test:harness list
|
|
11
|
+
*
|
|
12
|
+
* Response format (array of keystrokes/text to send):
|
|
13
|
+
* ["n", "enter"] - Type 'n' then press enter (for confirm)
|
|
14
|
+
* ["enter"] - Press enter (accept default)
|
|
15
|
+
* ["down", "down", "enter"] - Arrow down twice, then enter (for select)
|
|
16
|
+
* ["My Pack Name", "enter"] - Type text then enter (for input)
|
|
17
|
+
*
|
|
18
|
+
* Special keys: enter, up, down, space, tab, escape
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as pty from 'node-pty'
|
|
22
|
+
import { mkdirSync, rmSync, readdirSync, existsSync, writeFileSync } from 'fs'
|
|
23
|
+
import { join, dirname } from 'path'
|
|
24
|
+
import { fileURLToPath } from 'url'
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
const CLI_ROOT = join(__dirname, '..')
|
|
28
|
+
const CREATE_SCRIPT = join(CLI_ROOT, 'lib', 'create.js')
|
|
29
|
+
const SAND_SCRIPT = join(CLI_ROOT, 'lib', 'index.js')
|
|
30
|
+
const TEST_RUNS_DIR = join(CLI_ROOT, '.test-runs')
|
|
31
|
+
|
|
32
|
+
// Handle errors from node-pty by exiting cleanly
|
|
33
|
+
process.on('unhandledRejection', (err) => {
|
|
34
|
+
if (err instanceof Error && err.message.includes('Socket is closed')) {
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
console.error('Unhandled rejection:', err)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
process.on('uncaughtException', (err) => {
|
|
42
|
+
if (err.message.includes('Socket is closed')) {
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
console.error('Uncaught exception:', err)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const KEY_MAP: Record<string, string> = {
|
|
50
|
+
enter: '\r\n',
|
|
51
|
+
up: '\x1B[A',
|
|
52
|
+
down: '\x1B[B',
|
|
53
|
+
right: '\x1B[C',
|
|
54
|
+
left: '\x1B[D',
|
|
55
|
+
space: ' ',
|
|
56
|
+
tab: '\t',
|
|
57
|
+
escape: '\x1B',
|
|
58
|
+
backspace: '\x7F',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strip ANSI escape codes from text
|
|
62
|
+
function stripAnsi(text: string): string {
|
|
63
|
+
// eslint-disable-next-line no-control-regex
|
|
64
|
+
return text.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B\[\?[0-9;]*[hl]|\x07/g, '')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Clean up output: strip ANSI, normalize whitespace, remove empty lines
|
|
68
|
+
function cleanOutput(text: string): string {
|
|
69
|
+
return stripAnsi(text)
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map(line => line.trimEnd())
|
|
72
|
+
.filter(line => line.length > 0)
|
|
73
|
+
.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensureTestRunsDir(): void {
|
|
77
|
+
if (!existsSync(TEST_RUNS_DIR)) {
|
|
78
|
+
mkdirSync(TEST_RUNS_DIR, { recursive: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getTestRunDirs(): string[] {
|
|
83
|
+
if (!existsSync(TEST_RUNS_DIR)) return []
|
|
84
|
+
return readdirSync(TEST_RUNS_DIR, { withFileTypes: true })
|
|
85
|
+
.filter(d => d.isDirectory())
|
|
86
|
+
.map(d => join(TEST_RUNS_DIR, d.name))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createTestRunDir(name: string): string {
|
|
90
|
+
ensureTestRunsDir()
|
|
91
|
+
const dir = join(TEST_RUNS_DIR, name)
|
|
92
|
+
if (existsSync(dir)) {
|
|
93
|
+
rmSync(dir, { recursive: true, force: true })
|
|
94
|
+
}
|
|
95
|
+
mkdirSync(dir, { recursive: true })
|
|
96
|
+
return dir
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseResponses(responsesJson: string): string[][] {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(responsesJson)
|
|
102
|
+
if (!Array.isArray(parsed)) {
|
|
103
|
+
throw new Error('Responses must be an array')
|
|
104
|
+
}
|
|
105
|
+
return parsed
|
|
106
|
+
} catch (e) {
|
|
107
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
108
|
+
console.error(`Failed to parse responses: ${message}`)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function keystrokeToBytes(key: string): string {
|
|
114
|
+
return KEY_MAP[key.toLowerCase()] ?? key
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface RunResult {
|
|
118
|
+
output: string
|
|
119
|
+
exitCode: number | null
|
|
120
|
+
projectPath?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runWithPty(
|
|
124
|
+
command: string,
|
|
125
|
+
args: string[],
|
|
126
|
+
cwd: string,
|
|
127
|
+
responses: string[][]
|
|
128
|
+
): Promise<RunResult> {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const fullCommand = `${command} ${args.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`
|
|
131
|
+
|
|
132
|
+
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'
|
|
133
|
+
const shellArgs = process.platform === 'win32'
|
|
134
|
+
? ['-NoProfile', '-Command', fullCommand]
|
|
135
|
+
: ['-c', fullCommand]
|
|
136
|
+
|
|
137
|
+
const env: Record<string, string> = {}
|
|
138
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
139
|
+
if (value !== undefined && key !== 'CI') {
|
|
140
|
+
env[key] = value
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
env.FORCE_COLOR = '1'
|
|
144
|
+
env.TERM = 'xterm-256color'
|
|
145
|
+
|
|
146
|
+
const proc = pty.spawn(shell, shellArgs, {
|
|
147
|
+
name: 'xterm-256color',
|
|
148
|
+
cols: 120,
|
|
149
|
+
rows: 30,
|
|
150
|
+
cwd,
|
|
151
|
+
env,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
let responseIndex = 0
|
|
155
|
+
let outputBuffer = ''
|
|
156
|
+
let fullOutput = ''
|
|
157
|
+
let exited = false
|
|
158
|
+
|
|
159
|
+
proc.onExit(({ exitCode }) => {
|
|
160
|
+
exited = true
|
|
161
|
+
resolve({ output: fullOutput, exitCode })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
proc.onData((data) => {
|
|
165
|
+
fullOutput += data
|
|
166
|
+
outputBuffer += data
|
|
167
|
+
|
|
168
|
+
if (!exited && responseIndex < responses.length && outputBuffer.includes('?')) {
|
|
169
|
+
outputBuffer = ''
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
if (!exited && responseIndex < responses.length) {
|
|
172
|
+
const response = responses[responseIndex]
|
|
173
|
+
const bytes = response.map(keystrokeToBytes).join('')
|
|
174
|
+
try {
|
|
175
|
+
proc.write(bytes)
|
|
176
|
+
} catch {
|
|
177
|
+
exited = true
|
|
178
|
+
resolve({ output: fullOutput, exitCode: null })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
responseIndex++
|
|
182
|
+
}
|
|
183
|
+
}, 100)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
if (!exited) {
|
|
189
|
+
proc.kill()
|
|
190
|
+
resolve({ output: fullOutput + '\n[TIMEOUT]', exitCode: null })
|
|
191
|
+
}
|
|
192
|
+
}, 30000)
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function writeLog(logPath: string, content: string): void {
|
|
197
|
+
writeFileSync(logPath, content, 'utf-8')
|
|
198
|
+
console.log(`Log written to: ${logPath}`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const commands: Record<string, (args: string[]) => Promise<void> | void> = {
|
|
202
|
+
async create(args) {
|
|
203
|
+
const responsesIdx = args.indexOf('--responses')
|
|
204
|
+
let responses: string[][] = []
|
|
205
|
+
let projectArgs = args
|
|
206
|
+
|
|
207
|
+
if (responsesIdx !== -1) {
|
|
208
|
+
const responsesJson = args[responsesIdx + 1]
|
|
209
|
+
responses = parseResponses(responsesJson)
|
|
210
|
+
projectArgs = [...args.slice(0, responsesIdx), ...args.slice(responsesIdx + 2)]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const projectName = projectArgs[0] || `test-${Date.now()}`
|
|
214
|
+
const remainingArgs = projectArgs.slice(1)
|
|
215
|
+
|
|
216
|
+
const testRunDir = createTestRunDir(projectName)
|
|
217
|
+
const projectPath = join(testRunDir, projectName)
|
|
218
|
+
|
|
219
|
+
console.log(`Creating project: ${projectName}`)
|
|
220
|
+
console.log(`Directory: ${testRunDir}`)
|
|
221
|
+
|
|
222
|
+
const result = await runWithPty('bun', [CREATE_SCRIPT, projectName, ...remainingArgs], testRunDir, responses)
|
|
223
|
+
|
|
224
|
+
const cleanedOutput = cleanOutput(result.output)
|
|
225
|
+
const logPath = join(testRunDir, 'test-run.log')
|
|
226
|
+
|
|
227
|
+
const logContent = [
|
|
228
|
+
`# Test Run: ${projectName}`,
|
|
229
|
+
`Date: ${new Date().toISOString()}`,
|
|
230
|
+
`Exit Code: ${result.exitCode}`,
|
|
231
|
+
`Project Path: ${projectPath}`,
|
|
232
|
+
'',
|
|
233
|
+
'## Output',
|
|
234
|
+
cleanedOutput,
|
|
235
|
+
].join('\n')
|
|
236
|
+
|
|
237
|
+
writeLog(logPath, logContent)
|
|
238
|
+
|
|
239
|
+
console.log(`Exit code: ${result.exitCode}`)
|
|
240
|
+
console.log(`Project: ${projectPath}`)
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async sand(args) {
|
|
244
|
+
const result = await runWithPty('bun', [SAND_SCRIPT, ...args], process.cwd(), [])
|
|
245
|
+
console.log(cleanOutput(result.output))
|
|
246
|
+
console.log(`Exit code: ${result.exitCode}`)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async run(args) {
|
|
250
|
+
const responsesIdx = args.indexOf('--responses')
|
|
251
|
+
let responses: string[][] = []
|
|
252
|
+
let cmdArgs = args
|
|
253
|
+
|
|
254
|
+
if (responsesIdx !== -1) {
|
|
255
|
+
const responsesJson = args[responsesIdx + 1]
|
|
256
|
+
responses = parseResponses(responsesJson)
|
|
257
|
+
cmdArgs = [...args.slice(0, responsesIdx), ...args.slice(responsesIdx + 2)]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const [cmd, ...rest] = cmdArgs
|
|
261
|
+
const result = await runWithPty(cmd, rest, process.cwd(), responses)
|
|
262
|
+
console.log(cleanOutput(result.output))
|
|
263
|
+
console.log(`Exit code: ${result.exitCode}`)
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
cleanup() {
|
|
267
|
+
const dirs = getTestRunDirs()
|
|
268
|
+
if (dirs.length === 0) {
|
|
269
|
+
console.log('No test runs to clean up')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log(`Cleaning up ${dirs.length} test runs:`)
|
|
274
|
+
for (const dir of dirs) {
|
|
275
|
+
console.log(` - ${dir}`)
|
|
276
|
+
rmSync(dir, { recursive: true, force: true })
|
|
277
|
+
}
|
|
278
|
+
console.log('Done')
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
list() {
|
|
282
|
+
const dirs = getTestRunDirs()
|
|
283
|
+
if (dirs.length === 0) {
|
|
284
|
+
console.log('No test runs')
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log(`Test runs (${dirs.length}):`)
|
|
289
|
+
for (const dir of dirs) {
|
|
290
|
+
const name = dir.split(/[/\\]/).pop()
|
|
291
|
+
const logExists = existsSync(join(dir, 'test-run.log'))
|
|
292
|
+
console.log(` ${name}${logExists ? ' (has log)' : ''}`)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
help() {
|
|
297
|
+
console.log(`
|
|
298
|
+
Sandstone CLI Test Harness
|
|
299
|
+
|
|
300
|
+
Commands:
|
|
301
|
+
bun test:harness create [name] [--responses '<json>']
|
|
302
|
+
Create a project with pre-programmed responses
|
|
303
|
+
Output saved to .test-runs/<name>/
|
|
304
|
+
|
|
305
|
+
bun test:harness run <cmd> [args...] [--responses '<json>']
|
|
306
|
+
Run arbitrary command with responses
|
|
307
|
+
|
|
308
|
+
bun test:harness sand <args...>
|
|
309
|
+
Run sand command
|
|
310
|
+
|
|
311
|
+
bun test:harness list
|
|
312
|
+
List test runs in .test-runs/
|
|
313
|
+
|
|
314
|
+
bun test:harness cleanup
|
|
315
|
+
Remove all test runs
|
|
316
|
+
|
|
317
|
+
Response Format:
|
|
318
|
+
[
|
|
319
|
+
["n", "enter"], # confirm: type 'n', press enter
|
|
320
|
+
["enter"], # select: accept default
|
|
321
|
+
["down", "down", "enter"],# select: navigate and select
|
|
322
|
+
["My Pack", "enter"], # input: type text, press enter
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
Special keys: enter, up, down, space, tab, escape, backspace
|
|
326
|
+
`)
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Main
|
|
331
|
+
const [cmd = 'help', ...args] = process.argv.slice(2)
|
|
332
|
+
|
|
333
|
+
const handler = commands[cmd]
|
|
334
|
+
if (handler) {
|
|
335
|
+
await handler(args)
|
|
336
|
+
} else {
|
|
337
|
+
console.log(`Unknown command: ${cmd}`)
|
|
338
|
+
commands.help([])
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
process.exit(0)
|
package/src/commands/create.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { confirm, select, input } from '@inquirer/prompts'
|
|
|
9
9
|
|
|
10
10
|
import { CLI_VERSION } from '../version.js'
|
|
11
11
|
import { capitalize, getWorldsList, hasBun, hasPnpm, hasYarn } from '../utils.js'
|
|
12
|
+
import { discoverAllInstances, type MinecraftInstance } from '../launchers/index.js'
|
|
12
13
|
|
|
13
14
|
type CreateOptions = {
|
|
14
15
|
// Flags
|
|
@@ -31,6 +32,92 @@ function toJson(obj: any, pretty = false): string {
|
|
|
31
32
|
})
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Parse Minecraft version from metadata (not from name) */
|
|
36
|
+
function parseVersion(version: string | undefined): number[] | null {
|
|
37
|
+
if (!version) return null
|
|
38
|
+
// Match version patterns like 1.21.6, 1.20
|
|
39
|
+
const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?/)
|
|
40
|
+
if (match) {
|
|
41
|
+
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3] || '0')]
|
|
42
|
+
}
|
|
43
|
+
// Snapshot format like 24w12a
|
|
44
|
+
const snapshotMatch = version.match(/^(\d+)w(\d+)/)
|
|
45
|
+
if (snapshotMatch) {
|
|
46
|
+
return [1, parseInt(snapshotMatch[1]), parseInt(snapshotMatch[2])]
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compare two version arrays (descending - newer first) */
|
|
52
|
+
function compareVersions(a: number[] | null, b: number[] | null): number {
|
|
53
|
+
if (!a && !b) return 0
|
|
54
|
+
if (!a) return 1 // null versions go to end
|
|
55
|
+
if (!b) return -1
|
|
56
|
+
for (let i = 0; i < 3; i++) {
|
|
57
|
+
if (a[i] !== b[i]) return b[i] - a[i] // descending
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Prompt user to select a Minecraft installation from detected instances */
|
|
63
|
+
async function selectClientInstance(): Promise<string | undefined> {
|
|
64
|
+
const { instances } = await discoverAllInstances()
|
|
65
|
+
|
|
66
|
+
if (instances.length === 0) {
|
|
67
|
+
return await input({ message: 'No Minecraft installations detected. Enter path to .minecraft folder:' })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Separate vanilla from other instances
|
|
71
|
+
const vanilla = instances.find(i => i.launcher === 'vanilla')
|
|
72
|
+
const otherInstances = instances.filter(i => i.launcher !== 'vanilla')
|
|
73
|
+
|
|
74
|
+
// Sort by version metadata (newest first), then alphabetically by name
|
|
75
|
+
otherInstances.sort((a, b) => {
|
|
76
|
+
const versionCmp = compareVersions(parseVersion(a.version), parseVersion(b.version))
|
|
77
|
+
if (versionCmp !== 0) return versionCmp
|
|
78
|
+
return a.name.localeCompare(b.name)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
type ChoiceValue = MinecraftInstance | 'none' | 'custom'
|
|
82
|
+
const choices: Array<{ name: string; value: ChoiceValue; short: string }> = []
|
|
83
|
+
|
|
84
|
+
// Add Custom and None at top
|
|
85
|
+
choices.push({ name: 'Custom path...', value: 'custom', short: 'Custom' })
|
|
86
|
+
choices.push({ name: 'None (configure later)', value: 'none', short: 'None' })
|
|
87
|
+
|
|
88
|
+
// Add Vanilla (default)
|
|
89
|
+
if (vanilla) {
|
|
90
|
+
choices.push({
|
|
91
|
+
name: `${vanilla.name} [${vanilla.launcher}]`,
|
|
92
|
+
value: vanilla,
|
|
93
|
+
short: vanilla.name,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add sorted instances (newest version first)
|
|
98
|
+
for (const i of otherInstances) {
|
|
99
|
+
choices.push({
|
|
100
|
+
name: `${i.name}${i.version ? ` (${i.version})` : ''} [${i.launcher}]`,
|
|
101
|
+
value: i,
|
|
102
|
+
short: i.name,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const selected = await select({
|
|
107
|
+
message: 'Select Minecraft installation:',
|
|
108
|
+
choices,
|
|
109
|
+
default: vanilla ?? 'none', // Vanilla is default, or None if Vanilla not present
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (selected === 'none') {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
if (selected === 'custom') {
|
|
116
|
+
return await input({ message: 'Enter path to .minecraft folder:' })
|
|
117
|
+
}
|
|
118
|
+
return selected.minecraftPath
|
|
119
|
+
}
|
|
120
|
+
|
|
34
121
|
export async function createCommand(_project: string, opts: CreateOptions) {
|
|
35
122
|
|
|
36
123
|
const projectPath = path.resolve(_project)
|
|
@@ -125,23 +212,33 @@ export async function createCommand(_project: string, opts: CreateOptions) {
|
|
|
125
212
|
})
|
|
126
213
|
|
|
127
214
|
switch (saveChoice) {
|
|
128
|
-
case 'root':
|
|
215
|
+
case 'root': {
|
|
216
|
+
const clientPath = await selectClientInstance()
|
|
217
|
+
if (clientPath) {
|
|
218
|
+
saveOptions.clientPath = clientPath
|
|
219
|
+
}
|
|
129
220
|
saveOptions.root = true
|
|
130
221
|
break
|
|
131
|
-
|
|
222
|
+
}
|
|
223
|
+
case 'world': {
|
|
224
|
+
const clientPath = await selectClientInstance()
|
|
225
|
+
if (clientPath) {
|
|
226
|
+
saveOptions.clientPath = clientPath
|
|
227
|
+
}
|
|
132
228
|
const world = await select({
|
|
133
229
|
message: 'What world do you want to save the packs in? >',
|
|
134
230
|
choices: getWorldsList(saveOptions.clientPath),
|
|
135
231
|
})
|
|
136
232
|
saveOptions.world = world
|
|
137
233
|
break
|
|
138
|
-
|
|
234
|
+
}
|
|
235
|
+
case 'server-path': {
|
|
139
236
|
const serverPath = await input({
|
|
140
237
|
message: 'Where is the server to save the packs in? Relative paths are accepted. >',
|
|
141
238
|
})
|
|
142
|
-
|
|
143
239
|
saveOptions.serverPath = serverPath
|
|
144
240
|
break
|
|
241
|
+
}
|
|
145
242
|
case 'none': break
|
|
146
243
|
}
|
|
147
244
|
}
|
|
@@ -177,7 +274,7 @@ export async function createCommand(_project: string, opts: CreateOptions) {
|
|
|
177
274
|
|
|
178
275
|
exec(`git checkout ${projectType}-${version[0]}`)
|
|
179
276
|
|
|
180
|
-
await fs.rm('.git', { force: true, recursive: true })
|
|
277
|
+
await fs.rm(path.join(projectPath, '.git'), { force: true, recursive: true })
|
|
181
278
|
|
|
182
279
|
exec(`${packageManager} install`)
|
|
183
280
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Re-export types
|
|
2
|
+
export type { LauncherType, MinecraftInstance, LauncherProvider, DiscoveryResult } from './types.js'
|
|
3
|
+
|
|
4
|
+
// Re-export registry functions
|
|
5
|
+
export { registerProvider, getProviders, getProvider, discoverAllInstances } from './registry.js'
|
|
6
|
+
|
|
7
|
+
// Import and register all built-in providers
|
|
8
|
+
import { registerProvider } from './registry.js'
|
|
9
|
+
import { vanillaProvider } from './providers/vanilla.js'
|
|
10
|
+
import { prismProvider } from './providers/prism.js'
|
|
11
|
+
import { modrinthProvider } from './providers/modrinth.js'
|
|
12
|
+
|
|
13
|
+
registerProvider(vanillaProvider)
|
|
14
|
+
registerProvider(prismProvider)
|
|
15
|
+
registerProvider(modrinthProvider)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import { Database } from 'bun:sqlite'
|
|
5
|
+
import type { LauncherProvider, MinecraftInstance } from '../types.js'
|
|
6
|
+
|
|
7
|
+
function getModrinthCandidatePaths(): string[] {
|
|
8
|
+
const home = os.homedir()
|
|
9
|
+
const paths: string[] = []
|
|
10
|
+
|
|
11
|
+
switch (os.platform()) {
|
|
12
|
+
case 'win32':
|
|
13
|
+
paths.push(path.join(os.homedir(), 'AppData/Roaming/ModrinthApp'))
|
|
14
|
+
break
|
|
15
|
+
case 'darwin':
|
|
16
|
+
paths.push(path.join(home, 'Library/Application Support/ModrinthApp'))
|
|
17
|
+
break
|
|
18
|
+
case 'linux':
|
|
19
|
+
default: {
|
|
20
|
+
// Check XDG_DATA_HOME first
|
|
21
|
+
const xdgDataHome = process.env.XDG_DATA_HOME
|
|
22
|
+
if (xdgDataHome) {
|
|
23
|
+
paths.push(path.join(xdgDataHome, 'ModrinthApp'))
|
|
24
|
+
}
|
|
25
|
+
// Standard location
|
|
26
|
+
paths.push(path.join(home, '.local/share/ModrinthApp'))
|
|
27
|
+
// Flatpak location
|
|
28
|
+
paths.push(path.join(home, '.var/app/com.modrinth.ModrinthApp/data/ModrinthApp'))
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return paths
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getModrinthDataPath(): string | null {
|
|
37
|
+
for (const candidate of getModrinthCandidatePaths()) {
|
|
38
|
+
if (fs.existsSync(candidate)) {
|
|
39
|
+
return candidate
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProfileRow {
|
|
46
|
+
path: string
|
|
47
|
+
name: string
|
|
48
|
+
game_version: string | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Query app.db for profile metadata */
|
|
52
|
+
function getProfilesFromDb(dataPath: string): Map<string, { name: string; version?: string }> {
|
|
53
|
+
const profiles = new Map<string, { name: string; version?: string }>()
|
|
54
|
+
const dbPath = path.join(dataPath, 'app.db')
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(dbPath)) return profiles
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const db = new Database(dbPath, { readonly: true })
|
|
60
|
+
const rows = db.query<ProfileRow, []>('SELECT path, name, game_version FROM profiles').all()
|
|
61
|
+
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
profiles.set(row.path, {
|
|
64
|
+
name: row.name,
|
|
65
|
+
version: row.game_version ?? undefined,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
db.close()
|
|
70
|
+
} catch {
|
|
71
|
+
// Database read failed
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return profiles
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const modrinthProvider: LauncherProvider = {
|
|
78
|
+
type: 'modrinth',
|
|
79
|
+
displayName: 'Modrinth App',
|
|
80
|
+
|
|
81
|
+
async isInstalled(): Promise<boolean> {
|
|
82
|
+
return getModrinthDataPath() !== null
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
getDataPath(): string | null {
|
|
86
|
+
return getModrinthDataPath()
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async discoverInstances(): Promise<MinecraftInstance[]> {
|
|
90
|
+
const dataPath = getModrinthDataPath()
|
|
91
|
+
if (!dataPath) return []
|
|
92
|
+
|
|
93
|
+
const profilesDir = path.join(dataPath, 'profiles')
|
|
94
|
+
if (!fs.existsSync(profilesDir)) return []
|
|
95
|
+
|
|
96
|
+
// Get profile metadata from database
|
|
97
|
+
const profileMetadata = getProfilesFromDb(dataPath)
|
|
98
|
+
|
|
99
|
+
const instances: MinecraftInstance[] = []
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const entries = fs.readdirSync(profilesDir, { withFileTypes: true })
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory()) continue
|
|
106
|
+
// Skip hidden folders
|
|
107
|
+
if (entry.name.startsWith('.')) continue
|
|
108
|
+
|
|
109
|
+
// Modrinth profiles ARE the minecraft directory (no subdirectory)
|
|
110
|
+
const minecraftPath = path.join(profilesDir, entry.name)
|
|
111
|
+
|
|
112
|
+
// Verify it looks like a minecraft directory (has saves or mods folder)
|
|
113
|
+
const hasSaves = fs.existsSync(path.join(minecraftPath, 'saves'))
|
|
114
|
+
const hasMods = fs.existsSync(path.join(minecraftPath, 'mods'))
|
|
115
|
+
if (!hasSaves && !hasMods) continue
|
|
116
|
+
|
|
117
|
+
// Get metadata from database
|
|
118
|
+
const metadata = profileMetadata.get(entry.name)
|
|
119
|
+
|
|
120
|
+
instances.push({
|
|
121
|
+
id: `modrinth-${entry.name}`,
|
|
122
|
+
name: metadata?.name || entry.name,
|
|
123
|
+
launcher: 'modrinth',
|
|
124
|
+
minecraftPath,
|
|
125
|
+
version: metadata?.version,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Directory read failed
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return instances
|
|
133
|
+
},
|
|
134
|
+
}
|