Skip to content

Commit 5e4f143

Browse files
yyq1025claude
andcommitted
[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 `<packageManager> 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 <noreply@anthropic.com>
1 parent a848411 commit 5e4f143

13 files changed

Lines changed: 177 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- [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))
12+
1113
### 🐛 Bug fixes
1214

1315
- [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))

packages/build-tools/src/common/__tests__/installDependencies.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
33
import { createMockLogger } from '../../__tests__/utils/logger';
44
import { Sentry } from '../../sentry';
55
import { PackageManager } from '../../utils/packageManager';
6-
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
6+
import {
7+
installDependenciesAsync,
8+
installDependenciesWithNpmCacheFallbackAsync,
9+
} from '../installDependencies';
710

811
jest.mock('@expo/turtle-spawn', () => jest.fn());
912
jest.mock('../../sentry', () => ({
@@ -12,6 +15,57 @@ jest.mock('../../sentry', () => ({
1215
},
1316
}));
1417

18+
describe(installDependenciesAsync, () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
it('runs "deno install" for deno projects', async () => {
24+
const logger = createMockLogger();
25+
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
26+
27+
await installDependenciesAsync({
28+
packageManager: PackageManager.DENO,
29+
env: {},
30+
logger,
31+
cwd: '/tmp/build',
32+
useFrozenLockfile: false,
33+
});
34+
35+
expect(spawn).toHaveBeenCalledWith('deno', ['install'], expect.any(Object));
36+
});
37+
38+
it('runs "deno install --frozen" when using a frozen lockfile', async () => {
39+
const logger = createMockLogger();
40+
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
41+
42+
await installDependenciesAsync({
43+
packageManager: PackageManager.DENO,
44+
env: {},
45+
logger,
46+
cwd: '/tmp/build',
47+
useFrozenLockfile: true,
48+
});
49+
50+
expect(spawn).toHaveBeenCalledWith('deno', ['install', '--frozen'], expect.any(Object));
51+
});
52+
53+
it('does not pass --verbose to deno when EAS_VERBOSE is set', async () => {
54+
const logger = createMockLogger();
55+
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
56+
57+
await installDependenciesAsync({
58+
packageManager: PackageManager.DENO,
59+
env: { EAS_VERBOSE: '1' },
60+
logger,
61+
cwd: '/tmp/build',
62+
useFrozenLockfile: false,
63+
});
64+
65+
expect(spawn).toHaveBeenCalledWith('deno', ['install'], expect.any(Object));
66+
});
67+
});
68+
1569
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
1670
beforeEach(() => {
1771
jest.clearAllMocks();

packages/build-tools/src/common/installDependencies.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,14 @@ export async function installDependenciesAsync({
6060
case PackageManager.BUN:
6161
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
6262
break;
63+
case PackageManager.DENO:
64+
args = ['install', ...(useFrozenLockfile ? ['--frozen'] : [])];
65+
break;
6366
default:
6467
throw new Error(`Unsupported package manager: ${packageManager}`);
6568
}
66-
if (env['EAS_VERBOSE'] === '1') {
69+
// `deno install` does not support a --verbose flag.
70+
if (env['EAS_VERBOSE'] === '1' && packageManager !== PackageManager.DENO) {
6771
args = [...args, '--verbose'];
6872
}
6973
logger.info(`Running "${packageManager} ${args.join(' ')}" in ${cwd} directory`);

packages/build-tools/src/steps/functions/prebuild.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function createPrebuildBuildFunction(): BuildFunction {
5959
await spawn('pnpm', argsWithExpo, options);
6060
} else if (packageManager === PackageManager.BUN) {
6161
await spawn('bun', argsWithExpo, options);
62+
} else if (packageManager === PackageManager.DENO) {
63+
await spawn('deno', ['run', '-A', 'npm:expo', ...prebuildCommandArgs], options);
6264
} else {
6365
throw new Error(`Unsupported package manager: ${packageManager}`);
6466
}

packages/build-tools/src/utils/__tests__/hooks.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ describe(runHookIfPresent, () => {
9898
expect(true).toBe(true);
9999
});
100100

101+
it('runs the hook with `deno task` when the project uses deno', async () => {
102+
vol.fromJSON(
103+
{
104+
'./package.json': JSON.stringify({
105+
scripts: {
106+
[Hook.POST_INSTALL]: 'echo post_install',
107+
},
108+
}),
109+
'./deno.lock': 'fakelockfile',
110+
},
111+
'/workingdir/build'
112+
);
113+
114+
await runHookIfPresent(ctx, Hook.POST_INSTALL);
115+
116+
expect(spawn).toBeCalledWith(
117+
PackageManager.DENO,
118+
['task', Hook.POST_INSTALL],
119+
expect.anything()
120+
);
121+
});
122+
101123
it('does not run the hook if not present in package.json', async () => {
102124
vol.fromJSON(
103125
{

packages/build-tools/src/utils/__tests__/packageManager.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,34 @@ describe(resolvePackageManager, () => {
7272
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
7373
});
7474

75+
it('returns deno when only deno.lock exists', async () => {
76+
await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content');
77+
expect(resolvePackageManager(rootDir, { env: {} })).toBe(PackageManager.DENO);
78+
});
79+
80+
it('returns yarn when both yarn.lock and deno.lock exist', async () => {
81+
// Node package manager lockfiles take precedence over deno.lock.
82+
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
83+
await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content');
84+
expect(resolvePackageManager(rootDir, { env: {} })).toBe(PackageManager.YARN);
85+
});
86+
87+
it('returns deno within a monorepo with deno.lock at the workspace root', async () => {
88+
await fs.writeFile(path.join(rootDir, 'deno.lock'), 'content');
89+
await fs.writeJson(path.join(rootDir, 'package.json'), {
90+
name: 'monorepo',
91+
workspaces: ['packages/*'],
92+
});
93+
94+
const nestedDir = path.join(rootDir, 'packages', 'expo-app');
95+
await fs.mkdirp(nestedDir);
96+
await fs.writeJson(path.join(nestedDir, 'package.json'), {
97+
name: '@monorepo/expo-app',
98+
});
99+
100+
expect(resolvePackageManager(nestedDir, { env: {} })).toBe(PackageManager.DENO);
101+
});
102+
75103
it('returns yarn when no lockfile and env var is an empty string', () => {
76104
expect(
77105
resolvePackageManager(rootDir, {
@@ -92,6 +120,12 @@ describe(resolvePackageManager, () => {
92120
);
93121
});
94122

123+
it('returns deno from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
124+
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'deno' } })).toBe(
125+
PackageManager.DENO
126+
);
127+
});
128+
95129
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
96130
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
97131
expect(
@@ -112,7 +146,7 @@ describe(resolvePackageManager, () => {
112146
const error = e as errors.UserError;
113147
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
114148
expect(error.message).toContain('bunn');
115-
expect(error.message).toContain('yarn, npm, pnpm, bun');
149+
expect(error.message).toContain('yarn, npm, pnpm, bun, deno');
116150
}
117151
});
118152
});

packages/build-tools/src/utils/__tests__/project.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ describe(runExpoCliCommand, () => {
8383
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
8484
expect(spawn).toHaveBeenCalledWith('bun', ['expo', 'doctor'], expect.any(Object));
8585
});
86+
87+
it('spawns expo via "deno" when package manager is deno', () => {
88+
const mockExpoConfig = mock<ExpoConfig>();
89+
when(mockExpoConfig.sdkVersion).thenReturn('46.0.0');
90+
const expoConfig = instance(mockExpoConfig);
91+
92+
const mockCtx = mock<BuildContext<Android.Job>>();
93+
when(mockCtx.packageManager).thenReturn(PackageManager.DENO);
94+
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
95+
const ctx = instance(mockCtx);
96+
97+
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
98+
expect(spawn).toHaveBeenCalledWith(
99+
'deno',
100+
['run', '-A', 'npm:expo', 'doctor'],
101+
expect.any(Object)
102+
);
103+
});
86104
});
87105
});
88106

packages/build-tools/src/utils/hooks.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export async function runHookIfPresent<TJob extends BuildJob>(
3434
ctx.packageManager === PackageManager.YARN && hook === Hook.PRE_INSTALL
3535
? PackageManager.NPM
3636
: ctx.packageManager;
37-
await spawn(packageManager, ['run', hook], {
37+
// `deno task` is deno's equivalent of `<packageManager> run` for package.json scripts.
38+
const runArgs = packageManager === PackageManager.DENO ? ['task', hook] : ['run', hook];
39+
await spawn(packageManager, runArgs, {
3840
cwd: projectDir,
3941
logger: ctx.logger,
4042
env: {

packages/build-tools/src/utils/packageManager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import path from 'path';
2+
13
import { errors } from '@expo/eas-build-job';
24
import { type bunyan } from '@expo/logger';
35
import * as PackageManagerUtils from '@expo/package-manager';
46
import { type BuildStepEnv } from '@expo/steps';
57
import spawnAsync from '@expo/turtle-spawn';
8+
import fs from 'fs-extra';
69
import semver from 'semver';
710
import { z } from 'zod';
811

@@ -11,6 +14,17 @@ export enum PackageManager {
1114
NPM = 'npm',
1215
PNPM = 'pnpm',
1316
BUN = 'bun',
17+
DENO = 'deno',
18+
}
19+
20+
export const DENO_LOCK_FILE = 'deno.lock';
21+
22+
function isUsingDeno(directory: string): boolean {
23+
if (fs.existsSync(path.join(directory, DENO_LOCK_FILE))) {
24+
return true;
25+
}
26+
const workspaceRoot = PackageManagerUtils.resolveWorkspaceRoot(directory);
27+
return workspaceRoot ? fs.existsSync(path.join(workspaceRoot, DENO_LOCK_FILE)) : false;
1428
}
1529

1630
export function resolvePackageManager(
@@ -30,6 +44,12 @@ export function resolvePackageManager(
3044
}
3145
} catch {}
3246

47+
// `@expo/package-manager` does not know about deno (yet), so a Node package
48+
// manager lockfile always wins; deno.lock only decides when none is present.
49+
if (isUsingDeno(directory)) {
50+
return PackageManager.DENO;
51+
}
52+
3353
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
3454
if (fallback) {
3555
const parsed = z.enum(PackageManager).safeParse(fallback);

packages/build-tools/src/utils/project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export function runExpoCliCommand({
6161
return spawn('pnpm', argsWithExpo, options);
6262
} else if (packageManager === PackageManager.BUN) {
6363
return spawn('bun', argsWithExpo, options);
64+
} else if (packageManager === PackageManager.DENO) {
65+
// deno has no bare `deno expo …` bin runner; `deno run -A npm:expo`
66+
// resolves the copy from local node_modules when present.
67+
return spawn('deno', ['run', '-A', 'npm:expo', ...args], options);
6468
} else {
6569
throw new Error(`Unsupported package manager: ${packageManager}`);
6670
}

0 commit comments

Comments
 (0)