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.
@@ -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)
@@ -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
- case 'world':
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
- case 'server-path':
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
+ }