77
88import { execSync } from 'child_process' ;
99import fs from 'fs' ;
10+ import os from 'os' ;
1011import path from 'path' ;
12+ import chalk from 'chalk' ;
13+ import { XMLParser } from 'fast-xml-parser' ;
14+ import ora , { Ora } from 'ora' ;
15+ import prompts from 'prompts' ;
1116import semver from 'semver' ;
1217import {
18+ getAppcastUrl ,
19+ MSE_DOWNLOAD_URLS ,
1320 MSE_MIN_VERSION ,
21+ MSE_TOS_CONTENT ,
1422 MSE_INSTALL_PATHS ,
1523 MSE_LINUX_CLI_NAME ,
1624} from './mse-config.js' ;
17- import type { Platform } from './types.js' ;
25+ import type { MSEInstallResult , Platform } from './types.js' ;
26+
27+ interface AppcastRelease {
28+ downloadUrl : string ;
29+ version : string | null ;
30+ buildNumber : string | null ;
31+ }
1832
1933/** Normalize version to major.minor.patch for comparison */
2034export function normalizeVersion ( v : string ) : string {
@@ -28,6 +42,62 @@ export function detectPlatform(): Platform {
2842 return p === 'darwin' || p === 'win32' ? p : 'linux' ;
2943}
3044
45+ async function fetchAppcastRelease ( platform : Platform ) : Promise < AppcastRelease | null > {
46+ try {
47+ const response = await fetch ( getAppcastUrl ( platform ) , {
48+ signal : AbortSignal . timeout ( 30000 ) ,
49+ } ) ;
50+
51+ if ( ! response . ok ) {
52+ console . warn ( chalk . yellow ( `Warning: Failed to fetch appcast: HTTP ${ response . status } ` ) ) ;
53+ return null ;
54+ }
55+
56+ const xmlContent = await response . text ( ) ;
57+
58+ const parser = new XMLParser ( {
59+ ignoreAttributes : false ,
60+ attributeNamePrefix : '@_' ,
61+ } ) ;
62+
63+ const parsed = parser . parse ( xmlContent ) ;
64+
65+ // Navigate to enclosure element: rss > channel > item > enclosure
66+ const channel = parsed ?. rss ?. channel ;
67+ if ( ! channel ) {
68+ console . warn ( chalk . yellow ( 'Warning: Invalid appcast format - missing channel' ) ) ;
69+ return null ;
70+ }
71+
72+ // Handle both single item and array of items
73+ const items = Array . isArray ( channel . item ) ? channel . item : [ channel . item ] ;
74+ const item = items [ 0 ] ;
75+ if ( ! item ?. enclosure ) {
76+ console . warn ( chalk . yellow ( 'Warning: No enclosure element found in appcast' ) ) ;
77+ return null ;
78+ }
79+
80+ const enclosure = item . enclosure ;
81+ const url = enclosure [ '@_url' ] ;
82+ if ( ! url || typeof url !== 'string' ) {
83+ console . warn ( chalk . yellow ( 'Warning: No URL found in appcast enclosure' ) ) ;
84+ return null ;
85+ }
86+
87+ const version = enclosure [ '@_sparkle:shortVersionString' ] ;
88+ const buildNumber = enclosure [ '@_sparkle:version' ] ;
89+
90+ return {
91+ downloadUrl : url ,
92+ version : typeof version === 'string' ? version : null ,
93+ buildNumber : typeof buildNumber === 'string' ? buildNumber : null ,
94+ } ;
95+ } catch ( error ) {
96+ console . warn ( chalk . yellow ( `Warning: Failed to fetch appcast: ${ ( error as Error ) . message } ` ) ) ;
97+ return null ;
98+ }
99+ }
100+
31101/** Get highest version directory (e.g., v20) from Windows install path */
32102function getHighestVersion ( directoryPath : string ) : string | null {
33103 try {
@@ -100,3 +170,268 @@ export function isVersionSufficient(installed: string, required: string = MSE_MI
100170 return false ;
101171 }
102172}
173+
174+ export async function showAndAcceptTOS ( ) : Promise < boolean > {
175+ console . log ( '\n' + chalk . bold . underline ( 'Meta Spatial Editor Terms and Privacy Policy' ) ) ;
176+ console . log ( chalk . gray ( '─' . repeat ( 60 ) ) ) ;
177+ console . log ( MSE_TOS_CONTENT ) ;
178+ console . log ( chalk . gray ( '─' . repeat ( 60 ) ) ) ;
179+
180+ const { accepted } = await prompts ( {
181+ type : 'select' ,
182+ name : 'accepted' ,
183+ message : 'Do you accept the Terms and Privacy Policy?' ,
184+ choices : [
185+ { title : 'Yes' , value : true } ,
186+ { title : 'No' , value : false } ,
187+ ] ,
188+ hint : 'Use arrow keys to select, Enter to confirm' ,
189+ } ) ;
190+
191+ return ! ! accepted ;
192+ }
193+
194+ async function downloadInstaller (
195+ platform : Platform ,
196+ spinner : Ora ,
197+ release ?: AppcastRelease | null ,
198+ ) : Promise < string > {
199+ const ext = { darwin : '.dmg' , win32 : '.msi' , linux : '.tar.gz' } [ platform ] ;
200+ const tempPath = path . join ( os . tmpdir ( ) , `MetaSpatialEditorInstaller${ ext } ` ) ;
201+
202+ let releaseInfo = release ;
203+ if ( ! releaseInfo ) {
204+ spinner . text = 'Fetching latest release information...' ;
205+ releaseInfo = await fetchAppcastRelease ( platform ) ;
206+ }
207+
208+ if ( ! releaseInfo ) {
209+ throw new Error ( 'Could not fetch release information. Please try again or download manually.' ) ;
210+ }
211+
212+ spinner . text = releaseInfo . version
213+ ? `Downloading Meta Spatial Editor ${ releaseInfo . version } ...`
214+ : 'Downloading Meta Spatial Editor installer...' ;
215+
216+ const downloadUrl = releaseInfo . downloadUrl . replace ( / & a m p ; / g, '&' ) ;
217+ const response = await fetch ( downloadUrl , { signal : AbortSignal . timeout ( 300000 ) } ) ;
218+
219+ if ( ! response . ok ) {
220+ throw new Error ( `Download failed: HTTP ${ response . status } ` ) ;
221+ }
222+
223+ const buffer = await response . arrayBuffer ( ) ;
224+ fs . writeFileSync ( tempPath , Buffer . from ( buffer ) ) ;
225+ return tempPath ;
226+ }
227+
228+ function silentInstallMacOS ( dmgPath : string , spinner : Ora ) : void {
229+ const mountPoint = path . join ( os . tmpdir ( ) , 'mse-installer-mount' ) ;
230+ const targetPath = '/Applications' ;
231+
232+ try {
233+ if ( ! fs . existsSync ( mountPoint ) ) {
234+ fs . mkdirSync ( mountPoint , { recursive : true } ) ;
235+ }
236+
237+ spinner . text = 'Mounting installer...' ;
238+ execSync ( `hdiutil attach "${ dmgPath } " -nobrowse -quiet -mountpoint "${ mountPoint } "` , {
239+ timeout : 60000 ,
240+ } ) ;
241+
242+ let appPath = path . join ( mountPoint , 'Meta Spatial Editor.app' ) ;
243+ let appName = 'Meta Spatial Editor.app' ;
244+
245+ if ( ! fs . existsSync ( appPath ) ) {
246+ const files = fs . readdirSync ( mountPoint ) ;
247+ const appFile = files . find ( ( f ) => f . endsWith ( '.app' ) ) ;
248+ if ( ! appFile ) throw new Error ( 'Could not find application in mounted DMG' ) ;
249+ appPath = path . join ( mountPoint , appFile ) ;
250+ appName = appFile ;
251+ }
252+
253+ spinner . text = `Installing ${ appName } ...` ;
254+ execSync ( `cp -R "${ appPath } " "${ targetPath } /"` , { timeout : 120000 } ) ;
255+ } finally {
256+ try {
257+ execSync ( `hdiutil detach "${ mountPoint } " -quiet` , { timeout : 30000 } ) ;
258+ } catch {
259+ // Ignore unmount errors
260+ }
261+ }
262+ }
263+
264+ function silentInstallWindows ( msiPath : string , spinner : Ora ) : void {
265+ const logPath = path . join ( os . tmpdir ( ) , 'mse-install.log' ) ;
266+
267+ spinner . info ( 'Administrator privileges required - a UAC prompt will appear.' ) ;
268+ console . log ( chalk . gray ( ' Please approve the prompt to continue installation.\n' ) ) ;
269+
270+ const psCommand = `Start-Process -FilePath 'msiexec' -ArgumentList '/i', '${ msiPath . replace ( / ' / g, "''" ) } ', '/quiet', '/norestart', '/log', '${ logPath . replace ( / ' / g, "''" ) } ' -Verb RunAs -Wait` ;
271+
272+ try {
273+ spinner . start ( 'Waiting for administrator approval...' ) ;
274+ execSync ( `powershell -Command "${ psCommand } "` , { timeout : 600000 } ) ;
275+ spinner . text = 'Installation completed, verifying...' ;
276+ } catch ( error ) {
277+ const errorMsg = ( error as Error ) . message || '' ;
278+ if ( errorMsg . includes ( 'canceled' ) || errorMsg . includes ( 'cancelled' ) ) {
279+ throw new Error ( 'Installation cancelled - administrator privileges are required to install.' ) ;
280+ }
281+ try {
282+ const logContent = fs . readFileSync ( logPath , 'utf16le' ) ;
283+ if ( logContent . includes ( '1925' ) ) {
284+ throw new Error ( 'Installation requires administrator privileges. Please run the terminal as Administrator or install manually.' ) ;
285+ }
286+ } catch {
287+ // Log file not readable, continue with original error
288+ }
289+ throw error ;
290+ }
291+ }
292+
293+ function silentInstallLinux ( archivePath : string , spinner : Ora ) : void {
294+ const installPath = MSE_INSTALL_PATHS . linux ;
295+ spinner . text = 'Extracting Meta Spatial Editor CLI...' ;
296+
297+ if ( ! fs . existsSync ( installPath ) ) {
298+ fs . mkdirSync ( installPath , { recursive : true } ) ;
299+ }
300+
301+ execSync ( `tar -xzf "${ archivePath } " -C "${ installPath } " --strip-components=1` , { timeout : 120000 } ) ;
302+
303+ const cliPath = path . join ( installPath , MSE_LINUX_CLI_NAME ) ;
304+ if ( fs . existsSync ( cliPath ) ) {
305+ execSync ( `chmod +x "${ cliPath } "` , { timeout : 5000 } ) ;
306+ }
307+
308+ const wrapperPath = path . join ( installPath , 'meta-spatial-editor-cli' ) ;
309+ if ( fs . existsSync ( wrapperPath ) ) {
310+ execSync ( `chmod +x "${ wrapperPath } "` , { timeout : 5000 } ) ;
311+ }
312+ }
313+
314+ function silentInstall ( installerPath : string , platform : Platform , spinner : Ora ) : void {
315+ if ( platform === 'darwin' ) silentInstallMacOS ( installerPath , spinner ) ;
316+ else if ( platform === 'win32' ) silentInstallWindows ( installerPath , spinner ) ;
317+ else silentInstallLinux ( installerPath , spinner ) ;
318+ }
319+
320+ export async function installMSE ( ) : Promise < MSEInstallResult > {
321+ const platform = detectPlatform ( ) ;
322+ const downloadUrl = MSE_DOWNLOAD_URLS [ platform ] || MSE_DOWNLOAD_URLS . default ;
323+ const productName = platform === 'linux' ? 'Meta Spatial Editor CLI' : 'Meta Spatial Editor' ;
324+
325+ console . log ( chalk . gray ( '\nChecking for latest version...' ) ) ;
326+ const latestRelease = await fetchAppcastRelease ( platform ) ;
327+ const latestVersion = latestRelease ?. version || null ;
328+
329+ const existingVersion = await detectMSEVersion ( platform ) ;
330+
331+ if ( existingVersion ) {
332+ const installedNormalized = normalizeVersion ( existingVersion ) ;
333+ const latestNormalized = latestVersion ? normalizeVersion ( latestVersion ) : null ;
334+
335+ if ( latestNormalized && installedNormalized === latestNormalized ) {
336+ console . log ( chalk . green ( `\n✓ ${ productName } ${ existingVersion } is already installed (latest version).` ) ) ;
337+ return { installed : true , version : existingVersion , manual : false } ;
338+ }
339+
340+ if ( latestNormalized ) {
341+ try {
342+ if ( semver . gte ( installedNormalized , latestNormalized ) ) {
343+ console . log ( chalk . green ( `\n✓ ${ productName } ${ existingVersion } is already installed (up to date).` ) ) ;
344+ return { installed : true , version : existingVersion , manual : false } ;
345+ }
346+ } catch { }
347+
348+ const { shouldUpgrade } = await prompts ( {
349+ type : 'confirm' ,
350+ name : 'shouldUpgrade' ,
351+ message : `${ productName } ${ existingVersion } is installed. Upgrade to ${ latestVersion } ?` ,
352+ initial : true ,
353+ } ) ;
354+
355+ if ( ! shouldUpgrade ) {
356+ console . log ( chalk . gray ( 'Skipping upgrade.' ) ) ;
357+ return {
358+ installed : true ,
359+ version : existingVersion ,
360+ manual : false ,
361+ outdated : ! isVersionSufficient ( existingVersion ) ,
362+ } ;
363+ }
364+ } else if ( isVersionSufficient ( existingVersion ) ) {
365+ console . log ( chalk . green ( `\n✓ ${ productName } ${ existingVersion } is already installed.` ) ) ;
366+ console . log ( chalk . gray ( '(Could not check for updates - using installed version)' ) ) ;
367+ return { installed : true , version : existingVersion , manual : false } ;
368+ } else {
369+ const { shouldUpgrade } = await prompts ( {
370+ type : 'confirm' ,
371+ name : 'shouldUpgrade' ,
372+ message : `${ productName } ${ existingVersion } is installed but version ${ MSE_MIN_VERSION } + is required. Try to upgrade?` ,
373+ initial : true ,
374+ } ) ;
375+
376+ if ( ! shouldUpgrade ) {
377+ return { installed : true , version : existingVersion , manual : false , outdated : true } ;
378+ }
379+ }
380+ } else if ( ! latestRelease ) {
381+ console . log ( chalk . yellow ( `\nCould not fetch ${ productName } release information.` ) ) ;
382+ console . log ( 'Please install manually:' ) ;
383+ console . log ( chalk . cyan ( ` ${ downloadUrl } ` ) ) ;
384+ return { installed : false , version : null , manual : true } ;
385+ }
386+
387+ const tosAccepted = await showAndAcceptTOS ( ) ;
388+ if ( ! tosAccepted ) {
389+ console . log ( chalk . yellow ( '\nInstallation cancelled - Terms not accepted.' ) ) ;
390+ console . log ( 'You can install Meta Spatial Editor manually later:' ) ;
391+ console . log ( chalk . cyan ( ` ${ downloadUrl } ` ) ) ;
392+ return { installed : false , version : null , manual : true } ;
393+ }
394+
395+ const spinner = ora ( { text : 'Preparing installation...' , stream : process . stderr } ) . start ( ) ;
396+
397+ try {
398+ const installerPath = await downloadInstaller ( platform , spinner , latestRelease ) ;
399+ silentInstall ( installerPath , platform , spinner ) ;
400+
401+ try {
402+ fs . unlinkSync ( installerPath ) ;
403+ } catch {
404+ // Ignore cleanup errors
405+ }
406+
407+ // Brief delay for macOS to register the app
408+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
409+
410+ spinner . text = 'Verifying installation...' ;
411+ const version = await detectMSEVersion ( platform ) ;
412+
413+ if ( version && isVersionSufficient ( version ) ) {
414+ spinner . succeed ( `${ productName } ${ version } installed successfully` ) ;
415+
416+ if ( platform === 'linux' ) {
417+ const cliPath = path . join ( MSE_INSTALL_PATHS . linux , MSE_LINUX_CLI_NAME ) ;
418+ console . log ( chalk . cyan ( `\nCLI installed to: ${ cliPath } ` ) ) ;
419+ console . log ( chalk . gray ( `Set environment variable: META_SPATIAL_EDITOR_CLI_PATH=${ cliPath } ` ) ) ;
420+ }
421+
422+ return { installed : true , version, manual : false } ;
423+ } else if ( version ) {
424+ spinner . succeed ( `${ productName } ${ version } installed (version above ${ MSE_MIN_VERSION } recommended)` ) ;
425+ return { installed : true , version, manual : false , outdated : true } ;
426+ } else {
427+ spinner . warn ( 'Installation completed but version verification failed' ) ;
428+ return { installed : false , version : null , manual : true } ;
429+ }
430+ } catch ( error ) {
431+ spinner . fail ( 'Installation failed' ) ;
432+ console . error ( chalk . red ( `Error: ${ ( error as Error ) . message } ` ) ) ;
433+ console . log ( '\nYou can install Meta Spatial Editor manually:' ) ;
434+ console . log ( chalk . cyan ( ` ${ downloadUrl } ` ) ) ;
435+ return { installed : false , version : null , manual : true , error : error as Error } ;
436+ }
437+ }
0 commit comments