Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion packages/build-tools/src/common/installDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 2 additions & 0 deletions packages/build-tools/src/steps/functions/prebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
22 changes: 22 additions & 0 deletions packages/build-tools/src/utils/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
36 changes: 35 additions & 1 deletion packages/build-tools/src/utils/__tests__/packageManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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(
Expand All @@ -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');
}
});
});
Expand Down
18 changes: 18 additions & 0 deletions packages/build-tools/src/utils/__tests__/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpoConfig>();
when(mockExpoConfig.sdkVersion).thenReturn('46.0.0');
const expoConfig = instance(mockExpoConfig);

const mockCtx = mock<BuildContext<Android.Job>>();
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)
);
});
});
});

Expand Down
4 changes: 3 additions & 1 deletion packages/build-tools/src/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export async function runHookIfPresent<TJob extends BuildJob>(
ctx.packageManager === PackageManager.YARN && hook === Hook.PRE_INSTALL
? PackageManager.NPM
: ctx.packageManager;
await spawn(packageManager, ['run', hook], {
// `deno task` is deno's equivalent of `<packageManager> run` for package.json scripts.
const runArgs = packageManager === PackageManager.DENO ? ['task', hook] : ['run', hook];
await spawn(packageManager, runArgs, {
cwd: projectDir,
logger: ctx.logger,
env: {
Expand Down
20 changes: 20 additions & 0 deletions packages/build-tools/src/utils/packageManager.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/build-tools/src/utils/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/eas-build-job/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions packages/eas-cli/src/build/__tests__/validateLockfile-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/eas-cli/src/build/validateLockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down
Loading