expo-desktop 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -23,6 +23,11 @@ const main = defineCommand({
23
23
  description: `The ${kleur.bold("display name")} for the app ${grey("(Examples: 'My App 123', '俺のアプリ')")}`,
24
24
  valueHint: "name",
25
25
  },
26
+ "local-dev": {
27
+ type: "boolean",
28
+ description: "An undocumented switch for use during development to skip the questionnaire.",
29
+ hidden: true,
30
+ },
26
31
  rdns: {
27
32
  type: "string",
28
33
  description: `The ${kleur.bold("reverse DNS")} for the app ${grey("(Example: 'com.example.my-app-123')")}`,
@@ -0,0 +1,369 @@
1
+ import { glob } from "glob";
2
+ import mustache from "mustache";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import packageJson from "../../package.json" with { type: "json" };
8
+ /**
9
+ * Mirrors the effective behaviour of `vnext/templates/cpp-app/template.config.js`
10
+ * from react-native-windows (path renames + Mustache replacements) without
11
+ * executing that file (it pulls undeclared deps and `../templateUtils`).
12
+ *
13
+ * @see https://github.com/microsoft/react-native-windows/blob/main/vnext/templates/cpp-app/template.config.js
14
+ * @see https://github.com/microsoft/react-native-windows/blob/main/packages/%40react-native-windows/cli/src/generator-common/index.ts
15
+ */
16
+ export async function applyWindowsCppAppTemplateAsync(projectRoot, name) {
17
+ const windowsRoot = path.join(projectRoot, "windows");
18
+ try {
19
+ await fs.access(windowsRoot);
20
+ }
21
+ catch (error) {
22
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
23
+ throw error;
24
+ }
25
+ return;
26
+ }
27
+ if (!(await looksLikeCppAppTemplateAsync(windowsRoot))) {
28
+ return;
29
+ }
30
+ const replacements = await buildReplacementsRecord(projectRoot, name);
31
+ await renameCppAppPathsAsync(windowsRoot, name.filesafeName);
32
+ await renderMustacheUnderWindowsAsync(windowsRoot, replacements);
33
+ await finalizeWindowsCppTemplateArtifacts(projectRoot, windowsRoot);
34
+ }
35
+ async function looksLikeCppAppTemplateAsync(windowsRoot) {
36
+ try {
37
+ await fs.access(path.join(windowsRoot, "MyApp"));
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
42
+ throw error;
43
+ }
44
+ const entries = await fs.readdir(windowsRoot).catch(() => []);
45
+ return entries.some((e) => e.includes("MyApp"));
46
+ }
47
+ }
48
+ /** Same as legacy `react-native init-windows --namespace` (strip `-` and `_` only). */
49
+ function windowsNamespaceFromRdns(rdns) {
50
+ return rdns.replaceAll(/[-_]/g, "");
51
+ }
52
+ async function tryReadRnwFromNodeModules(projectRoot) {
53
+ const pkgPath = path.join(projectRoot, "node_modules", "react-native-windows", "package.json");
54
+ try {
55
+ const raw = await fs.readFile(pkgPath, "utf8");
56
+ const version = JSON.parse(raw).version;
57
+ if (!version) {
58
+ return null;
59
+ }
60
+ const rnwPath = path.dirname(pkgPath);
61
+ let devMode = false;
62
+ try {
63
+ await fs.access(path.join(rnwPath, "src-win"));
64
+ devMode = true;
65
+ }
66
+ catch (error) {
67
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
68
+ throw error;
69
+ }
70
+ devMode = false;
71
+ }
72
+ return { path: rnwPath, version, devMode };
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
76
+ throw error;
77
+ }
78
+ return null;
79
+ }
80
+ }
81
+ async function reactNativeWindowsVersionFromPackageJson(projectRoot) {
82
+ const pkgPath = path.join(projectRoot, "package.json");
83
+ try {
84
+ const raw = await fs.readFile(pkgPath, "utf8");
85
+ const parsed = JSON.parse(raw);
86
+ const v = parsed.dependencies?.["react-native-windows"];
87
+ return typeof v === "string" ? v : null;
88
+ }
89
+ catch (error) {
90
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
91
+ throw error;
92
+ }
93
+ return null;
94
+ }
95
+ }
96
+ async function buildReplacementsRecord(projectRoot, name) {
97
+ const projectGuid = crypto.randomUUID();
98
+ const packageGuid = crypto.randomUUID();
99
+ const namespace = windowsNamespaceFromRdns(name.rdns);
100
+ const namespaceCpp = namespace.replaceAll(".", "::");
101
+ const mainComponentName = name.filesafeName;
102
+ const rnw = await tryReadRnwFromNodeModules(projectRoot);
103
+ const rnwVersion = rnw?.version ?? (await reactNativeWindowsVersionFromPackageJson(projectRoot)) ?? "0.0.0";
104
+ const rnwPathFromProjectRoot = rnw
105
+ ? path.relative(projectRoot, rnw.path).replaceAll("/", "\\")
106
+ : "node_modules\\react-native-windows";
107
+ const devMode = rnw?.devMode ?? false;
108
+ const isCanary = rnwVersion.includes("canary");
109
+ return {
110
+ name: name.filesafeName,
111
+ namespace,
112
+ namespaceCpp,
113
+ rnwVersion,
114
+ rnwPathFromProjectRoot,
115
+ mainComponentName,
116
+ projectGuidLower: `{${projectGuid.toLowerCase()}}`,
117
+ projectGuidUpper: `{${projectGuid.toUpperCase()}}`,
118
+ packageGuidLower: `{${packageGuid.toLowerCase()}}`,
119
+ packageGuidUpper: `{${packageGuid.toUpperCase()}}`,
120
+ currentUser: os.userInfo().username,
121
+ devMode,
122
+ useNuGets: !devMode,
123
+ addReactNativePublicAdoFeed: true || isCanary,
124
+ cppNugetPackages: [],
125
+ autolinkPropertiesForProps: "",
126
+ autolinkProjectReferencesForTargets: "",
127
+ autolinkCppIncludes: "",
128
+ autolinkCppPackageProviders: "\n UNREFERENCED_PARAMETER(packageProviders);",
129
+ };
130
+ }
131
+ function cppAppRelativePathTransform(relativeToWindows, filesafeName) {
132
+ const parts = relativeToWindows.split(/[/\\]/);
133
+ const base = parts.at(-1) ?? "";
134
+ if (base === "_gitignore") {
135
+ parts[parts.length - 1] = ".gitignore";
136
+ }
137
+ else if (base === "NuGet_Config") {
138
+ parts[parts.length - 1] = "NuGet.config";
139
+ }
140
+ return parts.join(path.sep).split("MyApp").join(filesafeName);
141
+ }
142
+ function pathDepth(p) {
143
+ return p.split(path.sep).filter(Boolean).length;
144
+ }
145
+ async function collectWindowsPathsAsync(windowsRoot) {
146
+ const out = [];
147
+ async function walk(current) {
148
+ let stat;
149
+ try {
150
+ stat = await fs.lstat(current);
151
+ }
152
+ catch (error) {
153
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
154
+ throw error;
155
+ }
156
+ return;
157
+ }
158
+ if (stat.isSymbolicLink()) {
159
+ return;
160
+ }
161
+ if (stat.isDirectory()) {
162
+ out.push(current);
163
+ for (const ent of await fs.readdir(current, { withFileTypes: true })) {
164
+ await walk(path.join(current, ent.name));
165
+ }
166
+ return;
167
+ }
168
+ if (stat.isFile()) {
169
+ out.push(current);
170
+ }
171
+ }
172
+ await walk(windowsRoot);
173
+ return out;
174
+ }
175
+ /**
176
+ * Renames cpp-app paths containing `MyApp` → filesafeName. Uses iterative
177
+ * shallow-first ordering (directories before files at the same depth). Deeper
178
+ * fixes caused ENOTEMPTY: files were renamed into `*.Package/Images` before the
179
+ * directory `MyApp.Package/Images` moved, leaving a non-empty destination.
180
+ */
181
+ async function renameCppAppPathsAsync(windowsRoot, filesafeName) {
182
+ for (;;) {
183
+ const allPaths = await collectWindowsPathsAsync(windowsRoot);
184
+ const candidates = [];
185
+ for (const abs of allPaths) {
186
+ const rel = path.relative(windowsRoot, abs);
187
+ if (!rel || rel.includes("..")) {
188
+ continue;
189
+ }
190
+ if (!rel.includes("MyApp")) {
191
+ continue;
192
+ }
193
+ let stat;
194
+ try {
195
+ stat = await fs.lstat(abs);
196
+ }
197
+ catch (error) {
198
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
199
+ throw error;
200
+ }
201
+ continue;
202
+ }
203
+ if (stat.isSymbolicLink()) {
204
+ continue;
205
+ }
206
+ candidates.push({
207
+ abs,
208
+ rel,
209
+ depth: pathDepth(rel),
210
+ isDir: stat.isDirectory(),
211
+ });
212
+ }
213
+ if (!candidates.length) {
214
+ break;
215
+ }
216
+ candidates.sort((a, b) => {
217
+ if (a.depth !== b.depth) {
218
+ return a.depth - b.depth;
219
+ }
220
+ if (a.isDir !== b.isDir) {
221
+ return a.isDir ? -1 : 1;
222
+ }
223
+ return a.rel.localeCompare(b.rel);
224
+ });
225
+ const chosen = candidates[0];
226
+ const newRelWin = cppAppRelativePathTransform(chosen.rel, filesafeName);
227
+ if (newRelWin === chosen.rel) {
228
+ throw new Error(`Windows cpp-app rename: expected path containing MyApp to change: "${chosen.rel}"`);
229
+ }
230
+ const newAbs = path.join(windowsRoot, newRelWin);
231
+ if (newAbs === chosen.abs) {
232
+ continue;
233
+ }
234
+ await fs.mkdir(path.dirname(newAbs), { recursive: true });
235
+ try {
236
+ await fs.rename(chosen.abs, newAbs);
237
+ }
238
+ catch (cause) {
239
+ throw new Error(`Failed to rename "${chosen.abs}" -> "${newAbs}"`, { cause });
240
+ }
241
+ }
242
+ }
243
+ const BINARY_EXTENSIONS = new Set([
244
+ ".png",
245
+ ".jpg",
246
+ ".jpeg",
247
+ ".gif",
248
+ ".webp",
249
+ ".jar",
250
+ ".keystore",
251
+ ".ico",
252
+ ".pdb",
253
+ ".dll",
254
+ ".exe",
255
+ ".bin",
256
+ ]);
257
+ function isProbablyBinaryFile(filePath) {
258
+ return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
259
+ }
260
+ function adjustReplacementStringsForLineEndings(view, useCRLF) {
261
+ const out = { ...view };
262
+ for (const [key, value] of Object.entries(out)) {
263
+ if (typeof value === "string") {
264
+ out[key] = useCRLF ? value.replaceAll(/(?<!\r)\n/g, "\r\n") : value.replaceAll(/\r\n/g, "\n");
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ async function collectWindowsFilesAsync(windowsRoot) {
270
+ const all = await collectWindowsPathsAsync(windowsRoot);
271
+ const files = new Array();
272
+ for (const p of all) {
273
+ try {
274
+ if ((await fs.lstat(p)).isFile()) {
275
+ files.push(p);
276
+ }
277
+ }
278
+ catch (error) {
279
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
280
+ throw error;
281
+ }
282
+ // skip
283
+ }
284
+ }
285
+ return files;
286
+ }
287
+ async function renderMustacheUnderWindowsAsync(windowsRoot, view) {
288
+ const filePaths = await collectWindowsFilesAsync(windowsRoot);
289
+ await Promise.all(filePaths.map(async (absoluteFilePath) => {
290
+ if (isProbablyBinaryFile(absoluteFilePath)) {
291
+ return;
292
+ }
293
+ let content;
294
+ try {
295
+ content = await fs.readFile(absoluteFilePath, "utf8");
296
+ }
297
+ catch (error) {
298
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
299
+ throw error;
300
+ }
301
+ return;
302
+ }
303
+ if (!content.includes("{{")) {
304
+ return;
305
+ }
306
+ const useCRLF = content.includes("\r\n");
307
+ const adjustedView = adjustReplacementStringsForLineEndings(view, useCRLF);
308
+ const rendered = mustache.render(content, adjustedView);
309
+ if (rendered !== content) {
310
+ await fs.writeFile(absoluteFilePath, rendered, "utf8");
311
+ }
312
+ }));
313
+ }
314
+ async function finalizeWindowsCppTemplateArtifacts(projectRoot, windowsRoot) {
315
+ const gitignoreRelPaths = await glob("**/_gitignore", {
316
+ cwd: windowsRoot,
317
+ nodir: true,
318
+ dot: true,
319
+ });
320
+ for (const rel of gitignoreRelPaths.sort().reverse()) {
321
+ const fromAbs = path.join(windowsRoot, rel);
322
+ const toAbs = path.join(windowsRoot, path.dirname(rel), ".gitignore");
323
+ await renameIfExistsPreferDest(fromAbs, toAbs);
324
+ }
325
+ await renameIfExistsPreferDest(path.join(projectRoot, "NuGet_Config"), path.join(projectRoot, "NuGet.config"));
326
+ const version = packageJson.version;
327
+ const banner = `<!-- This project was created with expo-desktop ${version} -->`;
328
+ const reactNativeWindowsBanner = /<!--\s*This project was created with react-native-windows[^\n\r]*-->/g;
329
+ const vcxprojRelPaths = await glob("windows/**/*.vcxproj", {
330
+ cwd: projectRoot,
331
+ nodir: true,
332
+ dot: true,
333
+ });
334
+ await Promise.all(vcxprojRelPaths.map(async (relPath) => {
335
+ const absolutePath = path.join(projectRoot, relPath);
336
+ let contents;
337
+ try {
338
+ contents = await fs.readFile(absolutePath, "utf8");
339
+ }
340
+ catch {
341
+ return;
342
+ }
343
+ const replaced = contents.replace(reactNativeWindowsBanner, banner);
344
+ if (replaced !== contents) {
345
+ await fs.writeFile(absolutePath, replaced, "utf8");
346
+ }
347
+ }));
348
+ }
349
+ async function renameIfExistsPreferDest(from, to) {
350
+ try {
351
+ await fs.access(from);
352
+ }
353
+ catch (error) {
354
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
355
+ throw error;
356
+ }
357
+ return;
358
+ }
359
+ try {
360
+ await fs.rename(from, to);
361
+ }
362
+ catch (error) {
363
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
364
+ await fs.unlink(from);
365
+ return;
366
+ }
367
+ throw error;
368
+ }
369
+ }
@@ -0,0 +1,13 @@
1
+ import fs from "node:fs/promises";
2
+ /**
3
+ * Read the state of a file on first call of the generator, then run the
4
+ * generator again to restore it back to that value that was read.
5
+ */
6
+ export async function* preserveFile({ filePath, enable, }) {
7
+ if (!enable) {
8
+ return;
9
+ }
10
+ const fileBefore = await fs.readFile(filePath, "utf-8");
11
+ yield;
12
+ await fs.writeFile(filePath, fileBefore, "utf-8");
13
+ }
@@ -11,9 +11,10 @@ import fs from "node:fs/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
+ import { applyWindowsCppAppTemplateAsync } from "./apply-windows-cpp-app-template.js";
14
15
  import { promisifiedSpawnTask } from "./child-process.js";
15
16
  import { getTemplateFilesToRenameAsync, renameTemplateAppNameAsync, } from "./rename-template-app-name.js";
16
- export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, }) {
17
+ export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, respectTemplateConfig, }) {
17
18
  const descriptors = getOrderedTemplateDescriptors(selection, enabledPlatforms);
18
19
  if (!descriptors.length) {
19
20
  return;
@@ -29,17 +30,20 @@ export async function applySelectedTemplatesAsync({ projectRoot, selection, enab
29
30
  // windows:
30
31
  // - https://github.com/microsoft/react-native-windows/blob/3d64f71ed8495da6a0dcfc1f97bcb8f761986594/packages/%40react-native-windows/cli/src/generator-windows/index.ts#L57
31
32
  // - https://github.com/microsoft/react-native-windows/tree/main/vnext/templates/cpp-app
32
- for (const descriptor of descriptors) {
33
+ for (const [index, descriptor] of Object.entries(descriptors)) {
33
34
  const source = parseTemplateSource(descriptor.value);
34
- const extracted = await prepareTemplateSourceAsync(source);
35
+ const extracted = await prepareTemplateSourceAsync(`Extracting template ${parseInt(index) + 1}/${descriptors.length} (--template ${descriptor.key})`, source);
35
36
  try {
36
37
  const templateRoot = await resolveTemplateRootAsync(extracted, source);
37
- const templateConfig = await loadTemplateConfigAsync(templateRoot);
38
+ const templateConfig = respectTemplateConfig
39
+ ? await loadTemplateConfigAsync(templateRoot)
40
+ : undefined;
38
41
  await copyTemplateFilesAsync({
39
42
  sourceRoot: templateRoot,
40
43
  projectRoot,
41
44
  name,
42
45
  templateConfig,
46
+ forPlatform: descriptor.forPlatform,
43
47
  });
44
48
  }
45
49
  finally {
@@ -107,7 +111,7 @@ function parseTemplateSource(template) {
107
111
  }
108
112
  return { type: "npm", spec: template };
109
113
  }
110
- async function prepareTemplateSourceAsync(source) {
114
+ async function prepareTemplateSourceAsync(taskTitle, source) {
111
115
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "expo-desktop-template-"));
112
116
  const archivePath = path.join(tempRoot, "template.tgz");
113
117
  switch (source.type) {
@@ -144,7 +148,7 @@ async function prepareTemplateSourceAsync(source) {
144
148
  }
145
149
  await tasks([
146
150
  promisifiedSpawnTask({
147
- title: "extracting template",
151
+ title: taskTitle,
148
152
  command: "tar",
149
153
  args: ["-xzf", archivePath, "-C", tempRoot],
150
154
  }),
@@ -172,12 +176,12 @@ async function loadTemplateConfigAsync(templateRoot) {
172
176
  await fs.access(configPath);
173
177
  }
174
178
  catch {
175
- return null;
179
+ return;
176
180
  }
177
181
  const imported = (await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href)));
178
182
  return imported.default ?? imported;
179
183
  }
180
- async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateConfig, }) {
184
+ async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateConfig, forPlatform, }) {
181
185
  const mappings = templateConfig?.files?.length
182
186
  ? templateConfig.files.map((mapping) => ({
183
187
  from: path.join(sourceRoot, mapping.from),
@@ -213,6 +217,12 @@ async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateC
213
217
  filesafeName: name.filesafeName,
214
218
  files: filesToRename,
215
219
  });
220
+ if (forPlatform === "macos") {
221
+ await renameMacosUnderscoreGitignore(projectRoot);
222
+ }
223
+ if (forPlatform === "windows") {
224
+ await applyWindowsCppAppTemplateAsync(projectRoot, name);
225
+ }
216
226
  }
217
227
  async function discoverAllFilesAsync(sourceRoot) {
218
228
  const out = new Array();
@@ -263,3 +273,26 @@ async function applyExtraReplacementsAsync({ cwd, files, replacements, }) {
263
273
  function normalizeToPosixPath(input) {
264
274
  return input.replaceAll(path.sep, "/");
265
275
  }
276
+ async function renameMacosUnderscoreGitignore(projectRoot) {
277
+ const from = path.join(projectRoot, "macos", "_gitignore");
278
+ const to = path.join(projectRoot, "macos", ".gitignore");
279
+ try {
280
+ await fs.access(from);
281
+ }
282
+ catch (error) {
283
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
284
+ throw error;
285
+ }
286
+ return;
287
+ }
288
+ try {
289
+ await fs.rename(from, to);
290
+ }
291
+ catch (error) {
292
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
293
+ await fs.unlink(from);
294
+ return;
295
+ }
296
+ throw error;
297
+ }
298
+ }
@@ -3,20 +3,21 @@ import { default as kleur } from "kleur";
3
3
  import { green, grey } from "kleur/colors";
4
4
  import { platform } from "node:process";
5
5
  import { title } from "../common/clack.js";
6
- import { createExpoDesktopApp, localDev } from "./create-expo-desktop-app.js";
6
+ import { createExpoDesktopApp } from "./create-expo-desktop-app.js";
7
7
  import { previewFileTree } from "./preview-file-tree.js";
8
8
  import { promptForVersion } from "./prompt-for-version.js";
9
9
  export async function newExpoDesktopProject(args) {
10
- // A dev-time switch for skipping the questions
11
- const skip = localDev;
12
- if (skip) {
10
+ // A switch for skipping the questions
11
+ const localDev = args["local-dev"];
12
+ if (localDev) {
13
13
  await createExpoDesktopApp({
14
+ localDev,
14
15
  name: {
15
- displayName: "My App Display Name",
16
- filesafeName: "MyApp6",
17
- rdns: "com.example.my-app-123",
16
+ displayName: "Your App Display Name",
17
+ filesafeName: "YourApp456",
18
+ rdns: "uk.co.birchlabs.your-app-456",
18
19
  },
19
- packageManager: "bun",
20
+ packageManager: "pnpm",
20
21
  templates: {
21
22
  template: args.template,
22
23
  "template-ios": args["template-ios"],
@@ -57,6 +58,7 @@ export async function newExpoDesktopProject(args) {
57
58
  process.exit(0);
58
59
  }
59
60
  await createExpoDesktopApp({
61
+ localDev,
60
62
  name,
61
63
  packageManager,
62
64
  versions,
@@ -11,18 +11,10 @@ import { makePrettySummary } from "../common/arktype.js";
11
11
  import { promisifiedSpawnTask, SPAWN_DEBUG_LOG_GLOB } from "../common/child-process.js";
12
12
  import { title } from "../common/clack.js";
13
13
  import { packageManagerExec } from "../common/npm.js";
14
+ import { preserveFile } from "../common/preserve-file.js";
14
15
  import { applySelectedTemplatesAsync } from "../common/template.js";
15
- /**
16
- * A crude switch to use to help with local development.
17
- *
18
- * - Skips the questionnaire at the start.
19
- * - Installs the local copy of expo-desktop-config-plugins rather than pinning
20
- * to a published release.
21
- * - Adds the apply-config-plugins.mjs script.
22
- */
23
- export const localDev = false;
24
- export async function createExpoDesktopApp({ name, packageManager, templates, versions, }) {
25
- const { projectPath } = await createExpoApp({ name, packageManager, versions });
16
+ export async function createExpoDesktopApp({ localDev, name, packageManager, templates, versions, }) {
17
+ const { projectPath } = await createExpoApp({ localDev, name, packageManager, versions });
26
18
  await appendRootGitignoreSpawnDebugLogs(projectPath);
27
19
  const templateSelection = {
28
20
  // https://github.com/expo/expo/blob/sdk-54/templates/expo-template-blank-typescript
@@ -42,19 +34,26 @@ export async function createExpoDesktopApp({ name, packageManager, templates, ve
42
34
  selection: templateSelection,
43
35
  enabledPlatforms: ["ios", "android", "macos", "windows"],
44
36
  name,
37
+ // I had originally hoped to consume the template.config.js file provided by
38
+ // the template, but the prototype in cpp-app imports dependencies like
39
+ // "chalk", "lodash", "username", and "../templateUtils" that it doesn't
40
+ // declare in any package.json, so we can't reliably support it. Will
41
+ // revisit the idea in future.
42
+ respectTemplateConfig: false,
45
43
  });
46
44
  console.log(`${green("◆")} Applied templates.\n`);
47
45
  title("Altering app.json…", { spacing: 1 });
48
46
  await updateAppJson({ name, projectPath });
49
47
  title("Altering package.json…", { spacing: 1 });
50
48
  const { name: packageJsonName } = await updatePackageJson({
49
+ localDev,
51
50
  name,
52
51
  projectPath,
53
52
  versions,
54
53
  task: { type: "create" },
55
54
  });
56
55
  title("Installing dependencies…", { spacing: 1 });
57
- await npmInstall({ cwd: projectPath, packageManager });
56
+ await npmInstall({ asNewWorkspace: true, cwd: projectPath, packageManager });
58
57
  await updatePackageJson({
59
58
  name,
60
59
  projectPath,
@@ -94,7 +93,22 @@ export async function createExpoDesktopApp({ name, packageManager, templates, ve
94
93
  await writeBabelConfig({ projectPath });
95
94
  // TODO: Set up Windows app.cpp entrypoint
96
95
  }
97
- async function createExpoApp({ name, packageManager, versions, }) {
96
+ async function createExpoApp({ localDev, name, packageManager, versions, }) {
97
+ // `create-expo-app` aggravatingly reconfigures your workspace to use
98
+ // `nodeLinker: hoisted`, which sucks when creating sample projects inside
99
+ // this monorepo during local dev (even with `--no-install`). So we fight
100
+ // back.
101
+ //
102
+ // For non-local dev, it sounds like we can use `nodeLinker: isolated` as of
103
+ // Expo SDK 54, so I'm tempted to enforce that in created templates, too. But
104
+ // one thing at a time.
105
+ // - https://docs.expo.dev/more/create-expo/#pnpm
106
+ // - https://github.com/expo/expo/blob/222b3b12610d69784bab6c5a188a46ea388f866a/packages/create-expo/src/resolvePackageManager.ts#L109
107
+ const gen = preserveFile({
108
+ enable: localDev,
109
+ filePath: localDev ? path.resolve(import.meta.dirname, "../../../../pnpm-workspace.yaml") : "",
110
+ });
111
+ await gen.next();
98
112
  // `npm create` drops flags meant for create-expo-app unless you add `--`; use
99
113
  // `npx --yes` instead to forward args correctly and skip prompts.
100
114
  const command = packageManager === "npm" ? "npx" : packageManager;
@@ -127,6 +141,9 @@ async function createExpoApp({ name, packageManager, versions, }) {
127
141
  log.error(`Error running ${yellow("create expo-app")}${error instanceof Error ? `: ${error.message}` : "."}`);
128
142
  process.exit(1);
129
143
  }
144
+ finally {
145
+ await gen.next();
146
+ }
130
147
  return { projectPath };
131
148
  }
132
149
  async function appendRootGitignoreSpawnDebugLogs(projectPath) {
@@ -203,7 +220,7 @@ async function updateAppJson({ name, projectPath, }) {
203
220
  }
204
221
  console.log(`${green("◆")} Altered app.json.\n`);
205
222
  }
206
- async function updatePackageJson({ name, projectPath, task, versions, }) {
223
+ async function updatePackageJson({ localDev, name, projectPath, task, versions, }) {
207
224
  const packageJsonPath = path.resolve(projectPath, "package.json");
208
225
  let packageJson;
209
226
  try {
@@ -275,10 +292,41 @@ async function updatePackageJson({ name, projectPath, task, versions, }) {
275
292
  console.log(`${green("◆")} Altered package.json.\n`);
276
293
  return { name: nameBefore };
277
294
  }
278
- async function npmInstall({ cwd, packageManager, }) {
295
+ async function npmInstall({ asNewWorkspace, cwd, packageManager, }) {
279
296
  const command = packageManager;
280
297
  const args = ["install"];
281
298
  console.log(`${cyan("◆")} Running: ${yellow(`${command} ${args.join(" ")}`)}\n`);
299
+ // Unlike npm and bun, pnpm climbs up to install dependencies in the closest
300
+ // ancestor directory if there is one. This is particularly inconvenient
301
+ // during local dev when we're creating samples inside the monorepo.
302
+ if (asNewWorkspace && packageManager === "pnpm") {
303
+ // (1) Ensure a file name pnpm-workspace.yaml exists.
304
+ //
305
+ // (2) Also ensure that it uses nodeLinker: hoisted, as otherwise
306
+ // `:path => "#{config[:reactNativePath]}-macos"` predicts that there will
307
+ // be a react-native-macos directory right beside the react-native
308
+ // directory by just optimistically appending "-macos" on the end, like so:
309
+ // "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._941d99d35895d1a3626e14fd9f3b3666/node_modules/react-native" + "-macos"
310
+ //
311
+ // As this is not true with pnpm's default `nodeLinker: isolated`, we
312
+ // need to stick to `nodeLinker: hoisted` until we can rewrite the
313
+ // Podfile script to resolve it properly.
314
+ //
315
+ // This is consistent with what the Expo team do for pnpm and yarn:
316
+ // - https://docs.expo.dev/more/create-expo/#pnpm
317
+ // - https://github.com/expo/expo/blob/222b3b12610d69784bab6c5a188a46ea388f866a/packages/create-expo/src/resolvePackageManager.ts#L109
318
+ try {
319
+ await fs.writeFile(path.resolve(cwd, "pnpm-workspace.yaml"), "nodeLinker: hoisted\n", {
320
+ flag: "wx",
321
+ encoding: "utf-8",
322
+ });
323
+ }
324
+ catch (error) {
325
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
326
+ throw error;
327
+ }
328
+ }
329
+ }
282
330
  try {
283
331
  await tasks([
284
332
  promisifiedSpawnTask({
@@ -52,6 +52,7 @@ export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun,
52
52
  selection: templateSelection,
53
53
  enabledPlatforms: platforms,
54
54
  name: appName,
55
+ respectTemplateConfig: false,
55
56
  });
56
57
  log.info("Applied project templates for clean prebuild.", { withGuide: false });
57
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-desktop",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Best-effort desktop support for Expo",
5
5
  "keywords": [
6
6
  "android",
@@ -39,14 +39,16 @@
39
39
  "citty": "^0.2.2",
40
40
  "glob": "^10.5.0",
41
41
  "kleur": "^4.1.5",
42
+ "mustache": "^4.2.0",
42
43
  "toml": "^4.1.1",
43
- "expo-desktop-config-plugins": "1.1.17",
44
- "expo-desktop-prebuild-config": "1.0.6"
44
+ "expo-desktop-config-plugins": "1.1.19",
45
+ "expo-desktop-prebuild-config": "1.0.8"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@expo/config": "^12.0.13",
48
49
  "@expo/config-plugins": "^54.0.4",
49
50
  "@tsconfig/node24": "^24.0.4",
51
+ "@types/mustache": "^4.2.6",
50
52
  "@types/node": "^24.12.2",
51
53
  "@typescript/native-preview": "^7.0.0-dev.20260425.1",
52
54
  "vitest": "^3.2.4"