diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index 5b838f2..24b4588 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -1,5 +1,6 @@ /** - * Per-framework patches applied to a cloned GitHub repo's package.json before install. + * Per-framework patches applied to a cloned GitHub repo's package.json before install, and the + * `npm install` flags it needs. */ type Manifest = { @@ -24,7 +25,8 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; + patches?: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; + installFlags?: string[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -51,11 +53,13 @@ const WASM_BUNDLERS = { rollup: 'npm:@rollup/wasm-node@*' }; +const ROLLDOWN_WASM = { '@rolldown/binding-wasm32-wasi': '1.2.5' }; + const FRAMEWORK_RULES: FrameworkRule[] = [ // Vite 8.2+ { applies: (deps) => minorAtLeast(deps, 'vite', 8, 2), - patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })] + patches: () => [fill('devDependencies', ROLLDOWN_WASM)] }, // Vite 7 and earlier { @@ -69,6 +73,12 @@ const FRAMEWORK_RULES: FrameworkRule[] = [ const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); return dev ? [force('scripts', { dev })] : []; } + }, + // Nuxt 4+ + { + applies: (deps) => majorAtLeast(deps, 'nuxt', 4), + patches: () => [fill('dependencies', ROLLDOWN_WASM), force('overrides', WASM_BUNDLERS)], + installFlags: ['--legacy-peer-deps'] } ]; @@ -82,7 +92,7 @@ function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): str if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { return withoutTurbopack; } - return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); + return withoutTurbopack.replace(/\bnext\b(?:\s+dev\b)?(?!\s+[a-z])/, '$& --webpack'); } /** Highest major the spec could install. Null means no ceiling at all. */ @@ -130,6 +140,18 @@ function majorBelow(deps: DeclaredDeps, name: string, major: number): boolean { return highest !== null && highest < major; } +function parseManifest(manifestRaw: string): Manifest | null { + try { + return JSON.parse(manifestRaw) as Manifest; + } catch { + return null; + } +} + +function declaredDeps(manifest: Manifest): DeclaredDeps { + return new Map(Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -153,15 +175,9 @@ function alreadyPresent( export function patchClonedManifest( manifestRaw: string ): { patched: string; notes: string[] } | null { - let manifest: Manifest; - try { - manifest = JSON.parse(manifestRaw) as Manifest; - } catch { - return null; - } - const deps: DeclaredDeps = new Map( - Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) - ); + const manifest = parseManifest(manifestRaw); + if (!manifest) return null; + const deps = declaredDeps(manifest); const currentValue: CurrentValue = (section, name) => { const held = manifest[section]; return isRecord(held) ? held[name] : undefined; @@ -181,7 +197,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { - if (!rule.applies(deps)) continue; + if (!rule.applies(deps) || !rule.patches) continue; for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { @@ -201,3 +217,16 @@ export function patchClonedManifest( const patched = JSON.stringify(manifest, null, indent) + (manifestRaw.endsWith('\n') ? '\n' : ''); return { patched, notes }; } + +/** Deduplicated so two matching rules asking for the same flag pass it once. */ +export function resolveInstallArgs(manifestRaw: string): string[] { + const manifest = parseManifest(manifestRaw); + if (!manifest) return ['install']; + const deps = declaredDeps(manifest); + const flags = new Set(); + for (const rule of FRAMEWORK_RULES) { + if (!rule.applies(deps)) continue; + for (const flag of rule.installFlags ?? []) flags.add(flag); + } + return ['install', ...flags]; +} diff --git a/src/lib/ide/repo-source.ts b/src/lib/ide/repo-source.ts index 56c3e29..dad03fc 100644 --- a/src/lib/ide/repo-source.ts +++ b/src/lib/ide/repo-source.ts @@ -2,7 +2,7 @@ import { fetchRepoTree } from '$lib/github/api'; import { POD_HOME, readPodFile, writePodFile } from '$lib/pod/fs'; import { trackEvent } from '$lib/utils/useLazyTracking'; -import { patchClonedManifest } from './native-deps'; +import { patchClonedManifest, resolveInstallArgs } from './native-deps'; import { ANSI } from './shell-rc'; import type { BootContext, ProjectSource } from './project-source'; @@ -11,6 +11,8 @@ export type RepoRef = { owner: string; repo: string; ref: string; dir: string }; export function repoSource({ owner, repo, ref, dir }: RepoRef): ProjectSource { const url = `https://github.com/${owner}/${repo}`; const repoDir = `${POD_HOME}/${repo}`; + // Filled by prepare(), which reads the manifest install flags are resolved from. + let installArgs: string[] = ['install']; return { id: 'github', @@ -33,32 +35,40 @@ export function repoSource({ owner, repo, ref, dir }: RepoRef): ProjectSource { color: false }), // Patched before the first tab opens, so the editor shows the manifest install will see. - prepare: patchManifest, + prepare: async (ctx: BootContext): Promise => { + installArgs = await prepareManifest(ctx); + }, initialFile: (files: string[]) => files[0], - installCommands: () => [['install']], + installCommands: () => [installArgs], startCommand: resolveStartScript, trackBoot: () => trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` }) }; } -async function patchManifest(ctx: BootContext): Promise { +/** + * Patches the cloned manifest and returns the `npm install` args for it. Both come from the one + * read, so install does not re-read the file prepare() just wrote. + */ +async function prepareManifest(ctx: BootContext): Promise { const manifestPath = `${ctx.workdir}/package.json`; try { const raw = await readPodFile(ctx.pod, manifestPath); const result = patchClonedManifest(raw); if (!result) { ctx.write(`\r\n${ANSI.dim}No dependency patches apply to this repo.${ANSI.reset}\r\n`); - return; + return resolveInstallArgs(raw); } await writePodFile(ctx.pod, manifestPath, result.patched); // Header takes the leading blank line; the changes follow indented under it as one block. ctx.write(`\r\n${ANSI.dim}Modified package.json${ANSI.reset}\r\n`); for (const note of result.notes) ctx.write(`${ANSI.dim} ${note}${ANSI.reset}\r\n`); + return resolveInstallArgs(result.patched); } catch (error) { ctx.write( `\r\n${ANSI.coral}Could not patch package.json; installing the repo as cloned.${ANSI.reset}\r\n` ); console.warn('Could not patch the cloned manifest:', error); + return ['install']; } }