Skip to content

Commit 6c29008

Browse files
ycliu613meta-codesync[bot]
authored andcommitted
Add MSE installation and download logic
Summary: Add the core installation functionality for automatic Meta Spatial Editor (MSE) installation. Features added: - `fetchAppcastRelease()` - Fetches latest release info from TEDI appcast (Sparkle-format XML) - `showAndAcceptTOS()` - Displays Terms of Service with explicit accept/decline prompt - `downloadInstaller()` - Downloads installer (.dmg/.msi/.tar.gz) from TEDI CDN - `silentInstallMacOS()` - Mounts DMG and copies .app to /Applications - `silentInstallWindows()` - Runs MSI installer quietly via msiexec - `silentInstallLinux()` - Extracts tar.gz to ~/.local/lib - `installMSE()` - Main entry point that orchestrates the full install flow: - Checks for existing installation and version - Prompts for upgrade if outdated - Shows TOS acceptance prompt - Downloads and installs silently - Verifies installation success This is part 3 of 4 for the MSE auto-install feature. Reviewed By: wangpingsx Differential Revision: D91470651 fbshipit-source-id: 0bb8690df121eaafd0293ec1f4bb472e04a87b1b
1 parent 16fda9d commit 6c29008

File tree

3 files changed

+353
-1
lines changed

3 files changed

+353
-1
lines changed

packages/create/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"cross-spawn": "^7.0.6",
4444
"chalk": "^5.6.2",
4545
"commander": "^14.0.0",
46+
"fast-xml-parser": "^4.5.1",
4647
"semver": "^7.6.3",
4748
"ora": "^8.2.0",
4849
"prompts": "^2.4.2",

packages/create/src/mse-installer.ts

Lines changed: 336 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,28 @@
77

88
import { execSync } from 'child_process';
99
import fs from 'fs';
10+
import os from 'os';
1011
import 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';
1116
import semver from 'semver';
1217
import {
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 */
2034
export 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 */
32102
function 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(/&amp;/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

Comments
 (0)