From 5e4f143960b3a98581b1a533e4a5efc4efec38ed Mon Sep 17 00:00:00 2001 From: Yueqian Yang Date: Fri, 3 Jul 2026 17:22:16 -0500 Subject: [PATCH] [eas-cli] Add deno package manager support for builds Detect deno as the package manager when a project (or its workspace root) has a deno.lock lockfile and no Node package manager lockfile. During builds, install dependencies with `deno install` (`--frozen` when a frozen lockfile is requested), run the Expo CLI through `deno run -A npm:expo`, and execute EAS build lifecycle hooks with `deno task`, deno's equivalent of ` run`. deno.lock is also accepted by the lockfile presence check, and 'deno' is a valid requiredPackageManager metadata value and EAS_FALLBACK_PACKAGE_MANAGER value. Detection lives in build-tools for now because @expo/package-manager has no deno support; a Node lockfile always wins over deno.lock, so existing projects are unaffected. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + .../__tests__/installDependencies.test.ts | 56 ++++++++++++++++++- .../src/common/installDependencies.ts | 6 +- .../src/steps/functions/prebuild.ts | 2 + .../src/utils/__tests__/hooks.test.ts | 22 ++++++++ .../utils/__tests__/packageManager.test.ts | 36 +++++++++++- .../src/utils/__tests__/project.test.ts | 18 ++++++ packages/build-tools/src/utils/hooks.ts | 4 +- .../build-tools/src/utils/packageManager.ts | 20 +++++++ packages/build-tools/src/utils/project.ts | 4 ++ packages/eas-build-job/src/metadata.ts | 4 +- .../build/__tests__/validateLockfile-test.ts | 5 ++ .../eas-cli/src/build/validateLockfile.ts | 4 ++ 13 files changed, 177 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd94fe7868..e994d6612f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Detect deno as the package manager for projects with a `deno.lock` lockfile, and use `deno install` / `deno task` during builds. ([#3951](https://github.com/expo/eas-cli/pull/3951) by [@yyq1025](https://github.com/yyq1025)) + ### 🐛 Bug fixes - [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp)) diff --git a/packages/build-tools/src/common/__tests__/installDependencies.test.ts b/packages/build-tools/src/common/__tests__/installDependencies.test.ts index fb5b91502a..65a21ea14c 100644 --- a/packages/build-tools/src/common/__tests__/installDependencies.test.ts +++ b/packages/build-tools/src/common/__tests__/installDependencies.test.ts @@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; import { createMockLogger } from '../../__tests__/utils/logger'; import { Sentry } from '../../sentry'; import { PackageManager } from '../../utils/packageManager'; -import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies'; +import { + installDependenciesAsync, + installDependenciesWithNpmCacheFallbackAsync, +} from '../installDependencies'; jest.mock('@expo/turtle-spawn', () => jest.fn()); jest.mock('../../sentry', () => ({ @@ -12,6 +15,57 @@ jest.mock('../../sentry', () => ({ }, })); +describe(installDependenciesAsync, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('runs "deno install" for deno projects', async () => { + const logger = createMockLogger(); + jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult()))); + + await installDependenciesAsync({ + packageManager: PackageManager.DENO, + env: {}, + logger, + cwd: '/tmp/build', + useFrozenLockfile: false, + }); + + expect(spawn).toHaveBeenCalledWith('deno', ['install'], expect.any(Object)); + }); + + it('runs "deno install --frozen" when using a frozen lockfile', async () => { + const logger = createMockLogger(); + jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult()))); + + await installDependenciesAsync({ + packageManager: PackageManager.DENO, + env: {}, + logger, + cwd: '/tmp/build', + useFrozenLockfile: true, + }); + + expect(spawn).toHaveBeenCalledWith('deno', ['install', '--frozen'], expect.any(Object)); + }); + + it('does not pass --verbose to deno when EAS_VERBOSE is set', async () => { + const logger = createMockLogger(); + jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult()))); + + await installDependenciesAsync({ + packageManager: PackageManager.DENO, + env: { EAS_VERBOSE: '1' }, + logger, + cwd: '/tmp/build', + useFrozenLockfile: false, + }); + + expect(spawn).toHaveBeenCalledWith('deno', ['install'], expect.any(Object)); + }); +}); + describe(installDependenciesWithNpmCacheFallbackAsync, () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/build-tools/src/common/installDependencies.ts b/packages/build-tools/src/common/installDependencies.ts index bbb6150ea4..2633519bbd 100644 --- a/packages/build-tools/src/common/installDependencies.ts +++ b/packages/build-tools/src/common/installDependencies.ts @@ -60,10 +60,14 @@ export async function installDependenciesAsync({ case PackageManager.BUN: args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])]; break; + case PackageManager.DENO: + args = ['install', ...(useFrozenLockfile ? ['--frozen'] : [])]; + break; default: throw new Error(`Unsupported package manager: ${packageManager}`); } - if (env['EAS_VERBOSE'] === '1') { + // `deno install` does not support a --verbose flag. + if (env['EAS_VERBOSE'] === '1' && packageManager !== PackageManager.DENO) { args = [...args, '--verbose']; } logger.info(`Running "${packageManager} ${args.join(' ')}" in ${cwd} directory`); diff --git a/packages/build-tools/src/steps/functions/prebuild.ts b/packages/build-tools/src/steps/functions/prebuild.ts index 6782124b3f..2ed7a4ad5a 100644 --- a/packages/build-tools/src/steps/functions/prebuild.ts +++ b/packages/build-tools/src/steps/functions/prebuild.ts @@ -59,6 +59,8 @@ export function createPrebuildBuildFunction(): BuildFunction { await spawn('pnpm', argsWithExpo, options); } else if (packageManager === PackageManager.BUN) { await spawn('bun', argsWithExpo, options); + } else if (packageManager === PackageManager.DENO) { + await spawn('deno', ['run', '-A', 'npm:expo', ...prebuildCommandArgs], options); } else { throw new Error(`Unsupported package manager: ${packageManager}`); } diff --git a/packages/build-tools/src/utils/__tests__/hooks.test.ts b/packages/build-tools/src/utils/__tests__/hooks.test.ts index ba0da95d27..8a385aa7cd 100644 --- a/packages/build-tools/src/utils/__tests__/hooks.test.ts +++ b/packages/build-tools/src/utils/__tests__/hooks.test.ts @@ -98,6 +98,28 @@ describe(runHookIfPresent, () => { expect(true).toBe(true); }); + it('runs the hook with `deno task` when the project uses deno', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.POST_INSTALL]: 'echo post_install', + }, + }), + './deno.lock': 'fakelockfile', + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.POST_INSTALL); + + expect(spawn).toBeCalledWith( + PackageManager.DENO, + ['task', Hook.POST_INSTALL], + expect.anything() + ); + }); + it('does not run the hook if not present in package.json', async () => { vol.fromJSON( { diff --git a/packages/build-tools/src/utils/__tests__/packageManager.test.ts b/packages/build-tools/src/utils/__tests__/packageManager.test.ts index f26b25f195..0ec7fc0498 100644 --- a/packages/build-tools/src/utils/__tests__/packageManager.test.ts +++ b/packages/build-tools/src/utils/__tests__/packageManager.test.ts @@ -72,6 +72,34 @@ describe(resolvePackageManager, () => { expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn'); }); + it('returns deno when only deno.lock exists', async () => { + await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content'); + expect(resolvePackageManager(rootDir, { env: {} })).toBe(PackageManager.DENO); + }); + + it('returns yarn when both yarn.lock and deno.lock exist', async () => { + // Node package manager lockfiles take precedence over deno.lock. + await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content'); + await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content'); + expect(resolvePackageManager(rootDir, { env: {} })).toBe(PackageManager.YARN); + }); + + it('returns deno within a monorepo with deno.lock at the workspace root', async () => { + await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content'); + await fs.writeJson(path.join(rootDir, 'package.json'), { + name: 'monorepo', + workspaces: ['packages/*'], + }); + + const nestedDir = path.join(rootDir, 'packages', 'expo-app'); + await fs.mkdirp(nestedDir); + await fs.writeJson(path.join(nestedDir, 'package.json'), { + name: '@monorepo/expo-app', + }); + + expect(resolvePackageManager(nestedDir, { env: {} })).toBe(PackageManager.DENO); + }); + it('returns yarn when no lockfile and env var is an empty string', () => { expect( resolvePackageManager(rootDir, { @@ -92,6 +120,12 @@ describe(resolvePackageManager, () => { ); }); + it('returns deno from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => { + expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'deno' } })).toBe( + PackageManager.DENO + ); + }); + it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => { await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content'); expect( @@ -112,7 +146,7 @@ describe(resolvePackageManager, () => { const error = e as errors.UserError; expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER'); expect(error.message).toContain('bunn'); - expect(error.message).toContain('yarn, npm, pnpm, bun'); + expect(error.message).toContain('yarn, npm, pnpm, bun, deno'); } }); }); diff --git a/packages/build-tools/src/utils/__tests__/project.test.ts b/packages/build-tools/src/utils/__tests__/project.test.ts index 550fdfc8fb..53fb2538f3 100644 --- a/packages/build-tools/src/utils/__tests__/project.test.ts +++ b/packages/build-tools/src/utils/__tests__/project.test.ts @@ -83,6 +83,24 @@ describe(runExpoCliCommand, () => { void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); expect(spawn).toHaveBeenCalledWith('bun', ['expo', 'doctor'], expect.any(Object)); }); + + it('spawns expo via "deno" when package manager is deno', () => { + const mockExpoConfig = mock(); + when(mockExpoConfig.sdkVersion).thenReturn('46.0.0'); + const expoConfig = instance(mockExpoConfig); + + const mockCtx = mock>(); + when(mockCtx.packageManager).thenReturn(PackageManager.DENO); + when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig)); + const ctx = instance(mockCtx); + + void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); + expect(spawn).toHaveBeenCalledWith( + 'deno', + ['run', '-A', 'npm:expo', 'doctor'], + expect.any(Object) + ); + }); }); }); diff --git a/packages/build-tools/src/utils/hooks.ts b/packages/build-tools/src/utils/hooks.ts index ed8860c953..10ea84b4ce 100644 --- a/packages/build-tools/src/utils/hooks.ts +++ b/packages/build-tools/src/utils/hooks.ts @@ -34,7 +34,9 @@ export async function runHookIfPresent( ctx.packageManager === PackageManager.YARN && hook === Hook.PRE_INSTALL ? PackageManager.NPM : ctx.packageManager; - await spawn(packageManager, ['run', hook], { + // `deno task` is deno's equivalent of ` run` for package.json scripts. + const runArgs = packageManager === PackageManager.DENO ? ['task', hook] : ['run', hook]; + await spawn(packageManager, runArgs, { cwd: projectDir, logger: ctx.logger, env: { diff --git a/packages/build-tools/src/utils/packageManager.ts b/packages/build-tools/src/utils/packageManager.ts index b0916e03a6..a521532ce7 100644 --- a/packages/build-tools/src/utils/packageManager.ts +++ b/packages/build-tools/src/utils/packageManager.ts @@ -1,8 +1,11 @@ +import path from 'path'; + import { errors } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; import * as PackageManagerUtils from '@expo/package-manager'; import { type BuildStepEnv } from '@expo/steps'; import spawnAsync from '@expo/turtle-spawn'; +import fs from 'fs-extra'; import semver from 'semver'; import { z } from 'zod'; @@ -11,6 +14,17 @@ export enum PackageManager { NPM = 'npm', PNPM = 'pnpm', BUN = 'bun', + DENO = 'deno', +} + +export const DENO_LOCK_FILE = 'deno.lock'; + +function isUsingDeno(directory: string): boolean { + if (fs.existsSync(path.join(directory, DENO_LOCK_FILE))) { + return true; + } + const workspaceRoot = PackageManagerUtils.resolveWorkspaceRoot(directory); + return workspaceRoot ? fs.existsSync(path.join(workspaceRoot, DENO_LOCK_FILE)) : false; } export function resolvePackageManager( @@ -30,6 +44,12 @@ export function resolvePackageManager( } } catch {} + // `@expo/package-manager` does not know about deno (yet), so a Node package + // manager lockfile always wins; deno.lock only decides when none is present. + if (isUsingDeno(directory)) { + return PackageManager.DENO; + } + const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER; if (fallback) { const parsed = z.enum(PackageManager).safeParse(fallback); diff --git a/packages/build-tools/src/utils/project.ts b/packages/build-tools/src/utils/project.ts index 755b63ce79..1430812d98 100644 --- a/packages/build-tools/src/utils/project.ts +++ b/packages/build-tools/src/utils/project.ts @@ -61,6 +61,10 @@ export function runExpoCliCommand({ return spawn('pnpm', argsWithExpo, options); } else if (packageManager === PackageManager.BUN) { return spawn('bun', argsWithExpo, options); + } else if (packageManager === PackageManager.DENO) { + // deno has no bare `deno expo …` bin runner; `deno run -A npm:expo` + // resolves the copy from local node_modules when present. + return spawn('deno', ['run', '-A', 'npm:expo', ...args], options); } else { throw new Error(`Unsupported package manager: ${packageManager}`); } diff --git a/packages/eas-build-job/src/metadata.ts b/packages/eas-build-job/src/metadata.ts index 024a1f7990..a43365f4cd 100644 --- a/packages/eas-build-job/src/metadata.ts +++ b/packages/eas-build-job/src/metadata.ts @@ -153,7 +153,7 @@ export type Metadata = { /** * Which package manager will be used for the build. Determined based on lockfiles in the project directory. */ - requiredPackageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun'; + requiredPackageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'deno'; /** * Indicates if this is an iOS build for a simulator @@ -203,7 +203,7 @@ export const MetadataSchema = Joi.object({ runWithNoWaitFlag: Joi.boolean(), customWorkflowName: Joi.string(), developmentClient: Joi.boolean(), - requiredPackageManager: Joi.string().valid('npm', 'pnpm', 'yarn', 'bun'), + requiredPackageManager: Joi.string().valid('npm', 'pnpm', 'yarn', 'bun', 'deno'), simulator: Joi.boolean(), selectedImage: Joi.string(), customNodeVersion: Joi.string(), diff --git a/packages/eas-cli/src/build/__tests__/validateLockfile-test.ts b/packages/eas-cli/src/build/__tests__/validateLockfile-test.ts index bee06d3c90..1e418085bc 100644 --- a/packages/eas-cli/src/build/__tests__/validateLockfile-test.ts +++ b/packages/eas-cli/src/build/__tests__/validateLockfile-test.ts @@ -44,6 +44,11 @@ describe(ensureLockfileExistsAsync, () => { await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow(); }); + it('passes when deno.lock exists', async () => { + vol.fromJSON({ './deno.lock': '' }, '/app'); + await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow(); + }); + it('throws when no lockfile exists', async () => { vol.fromJSON({ './package.json': '{}' }, '/app'); await expect(ensureLockfileExistsAsync('/app')).rejects.toThrow( diff --git a/packages/eas-cli/src/build/validateLockfile.ts b/packages/eas-cli/src/build/validateLockfile.ts index 861a98ac3f..e30a066e92 100644 --- a/packages/eas-cli/src/build/validateLockfile.ts +++ b/packages/eas-cli/src/build/validateLockfile.ts @@ -9,12 +9,16 @@ import { import { pathExists } from 'fs-extra'; import path from 'path'; +// Not exported by @expo/package-manager, which has no deno support (yet). +const DENO_LOCK_FILE = 'deno.lock'; + const LOCKFILE_NAMES = [ NPM_LOCK_FILE, YARN_LOCK_FILE, PNPM_LOCK_FILE, BUN_LOCK_FILE, BUN_TEXT_LOCK_FILE, + DENO_LOCK_FILE, ]; async function hasLockfileAsync(dir: string): Promise {