import { access, stat } from 'node:fs/promises' import { constants } from 'node:fs' import { homedir, platform } from 'node:os' import { delimiter, join } from 'node:path' import { execFile } from 'node:child_process' import { createSpine38ImportOptions } from './spine38-project.mjs' const SUPPORTED_VERSIONS = new Set(['3.8', '4.0', '4.1', '4.2', '4.3']) async function executable(path) { if (!path) return false try { await access(path, platform() === 'win32' ? constants.F_OK : constants.X_OK) return true } catch { return false } } export async function findSpineExecutable() { const candidates = [] if (process.env.SPINE_EXECUTABLE) candidates.push(process.env.SPINE_EXECUTABLE) if (platform() === 'darwin') { candidates.push( '/Applications/Spine.app/Contents/MacOS/Spine', join(homedir(), 'Applications/Spine.app/Contents/MacOS/Spine'), ) } else if (platform() === 'win32') { const programFiles = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean) for (const root of programFiles) { candidates.push( join(root, 'Spine', 'Spine.com'), join(root, 'Esoteric Software', 'Spine', 'Spine.com'), join(root, 'Programs', 'Spine', 'Spine.com'), ) } // 支持用户将 Spine 安装在自定义目录并把该目录加入 Windows PATH。 for (const root of String(process.env.Path || process.env.PATH || '').split(delimiter).filter(Boolean)) { candidates.push(join(root.replace(/^"|"$/g, ''), 'Spine.com')) } } else { candidates.push('/opt/Spine/Spine.sh', join(homedir(), 'Spine/Spine.sh'), '/usr/local/bin/Spine', '/usr/bin/Spine') } for (const candidate of candidates) if (await executable(candidate)) return candidate return null } export function normalizeSpineVersion(value) { const match = /^(3\.8|4\.[0-3])(?:\.\d+)?$/.exec(String(value || '').trim()) if (!match || !SUPPORTED_VERSIONS.has(match[1])) throw new Error(`不支持的 Spine 版本:${value}`) return match[1] } function run(executablePath, args, { cwd, timeoutMs = 180000 } = {}) { return new Promise((resolve, reject) => { execFile(executablePath, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { const details = [stdout, stderr, error.message] .filter(Boolean) .join('\n') .replace(/^Licensed to:.*$/gim, 'Spine 授权信息已隐藏') .trim() reject(new Error(details || 'Spine 命令执行失败')) return } resolve({ stdout, stderr }) }) }) } export async function importSpineProject({ executablePath, inputPath, outputPath, projectName, spineVersion, workspaceRoot }) { const version = normalizeSpineVersion(spineVersion) const editorVersion = `${version}.xx` const importOptions = version === '3.8' ? createSpine38ImportOptions({ workspaceRoot, inputPath, outputPath }) : { inputPath, outputPath } const result = await run(executablePath, [ '--update', editorVersion, '--input', importOptions.inputPath, '--output', importOptions.outputPath, '--import', projectName, ], { cwd: importOptions.cwd }) const output = await stat(outputPath).catch(() => null) if (!output?.isFile() || output.size === 0) throw new Error('Spine 未生成有效的工程文件') const detected = /Starting:\s+Spine\s+([0-9.]+)/i.exec(`${result.stdout}\n${result.stderr}`)?.[1] || editorVersion return { ...result, editorVersion: detected, size: output.size } }