expo-desktop 0.1.13 → 0.1.15

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
@@ -33,6 +33,31 @@ const main = defineCommand({
33
33
  description: `The ${kleur.bold("minor version")} of React Native to align on ${grey("(Examples: '0.80', 'latest')")}`,
34
34
  valueHint: "version",
35
35
  },
36
+ template: {
37
+ type: "string",
38
+ description: "Base template source (tarball, npm spec, or GitHub owner/repo#ref:subpath)",
39
+ valueHint: "template",
40
+ },
41
+ "template-ios": {
42
+ type: "string",
43
+ description: "iOS-specific template source",
44
+ valueHint: "template",
45
+ },
46
+ "template-android": {
47
+ type: "string",
48
+ description: "Android-specific template source",
49
+ valueHint: "template",
50
+ },
51
+ "template-macos": {
52
+ type: "string",
53
+ description: "macOS-specific template source",
54
+ valueHint: "template",
55
+ },
56
+ "template-windows": {
57
+ type: "string",
58
+ description: "Windows-specific template source",
59
+ valueHint: "template",
60
+ },
36
61
  },
37
62
  async run({ args }) {
38
63
  (await import("./create-app/command.js")).newExpoDesktopProject(args);
@@ -70,6 +95,26 @@ const main = defineCommand({
70
95
  description: "Project template to clone from. File path pointing to a local tar file, npm package or a github repo",
71
96
  valueHint: "template",
72
97
  },
98
+ "template-ios": {
99
+ type: "string",
100
+ description: "iOS-specific template source",
101
+ valueHint: "template",
102
+ },
103
+ "template-android": {
104
+ type: "string",
105
+ description: "Android-specific template source",
106
+ valueHint: "template",
107
+ },
108
+ "template-macos": {
109
+ type: "string",
110
+ description: "macOS-specific template source",
111
+ valueHint: "template",
112
+ },
113
+ "template-windows": {
114
+ type: "string",
115
+ description: "Windows-specific template source",
116
+ valueHint: "template",
117
+ },
73
118
  platform: {
74
119
  type: "string",
75
120
  description: `Platforms to sync: macos, windows, desktop ${dim("(Default: desktop)")}`,
@@ -0,0 +1,141 @@
1
+ import { glob } from "glob";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const { MacOSConfig: { XcodeUtils }, } = require("expo-desktop-config-plugins");
5
+ /**
6
+ * Returns a list of files within a template matched by the resolved rename
7
+ * config.
8
+ *
9
+ * The rename config is resolved in the order of preference:
10
+ * Config provided as function param > defaultRenameConfig
11
+ */
12
+ export async function getTemplateFilesToRenameAsync(cwd, { renameConfig: userConfig, } = {}) {
13
+ let config = userConfig ?? [...defaultRenameConfig];
14
+ // Strip comments, trim whitespace, and remove empty lines.
15
+ config = config
16
+ .map((line) => line.split(/(?<!\\)#/, 2)[0]?.trim() ?? "")
17
+ .filter((line) => line !== "");
18
+ return await glob(config, {
19
+ cwd,
20
+ // `true` is consistent with .gitignore. Allows `*.xml` to match .xml files
21
+ // in all subdirs.
22
+ matchBase: true,
23
+ dot: true,
24
+ // Prevent climbing out of the template directory in case a template
25
+ // includes a symlink to an external directory.
26
+ follow: false,
27
+ });
28
+ }
29
+ /**
30
+ * # Background
31
+ *
32
+ * `@expo/cli` and `create-expo` extract a template from a tarball (whether from
33
+ * a local npm project or a GitHub repository), but these templates have a
34
+ * static name that needs to be updated to match whatever app name the user
35
+ * specified.
36
+ *
37
+ * By convention, the app name of all templates is "HelloWorld". During
38
+ * extraction, filepaths are transformed via `createEntryResolver()` in
39
+ * `createFileTransform.ts`, but the contents of files are left untouched.
40
+ * Technically, the contents used to be transformed during extraction as well,
41
+ * but due to poor configurability, we've moved to a post-extraction approach.
42
+ *
43
+ * # The new approach: Renaming the app post-extraction
44
+ *
45
+ * In this new approach, we take a list of file patterns, otherwise known as the
46
+ * "rename config" to determine explicitly which files – relative to the root of
47
+ * the template – to perform find-and-replace on, to update the app name.
48
+ *
49
+ * ## The rename config
50
+ *
51
+ * The rename config can be passed directly as a string array to
52
+ * `getTemplateFilesToRenameAsync()`.
53
+ *
54
+ * The file patterns are formatted as glob expressions to be interpreted by
55
+ * [glob](https://github.com/isaacs/node-glob). Comments are supported with
56
+ * the `#` symbol, both in the plain-text file and string array formats.
57
+ * Whitespace is trimmed and whitespace-only lines are ignored.
58
+ *
59
+ * If no rename config has been passed directly to
60
+ * `getTemplateFilesToRenameAsync()` then this default rename config will be
61
+ * used instead.
62
+ *
63
+ * @see https://github.com/expo/expo/pull/27212
64
+ * @see https://github.com/expo/expo/blob/main/packages/%40expo/cli/src/prebuild/renameTemplateAppName.ts
65
+ */
66
+ export const defaultRenameConfig = [
67
+ // Common
68
+ "!**/node_modules",
69
+ "app.json",
70
+ // Android
71
+ "android/**/*.gradle",
72
+ "android/app/BUCK",
73
+ "android/app/src/**/*.java",
74
+ "android/app/src/**/*.kt",
75
+ "android/app/src/**/*.xml",
76
+ // iOS
77
+ "ios/Podfile",
78
+ "ios/**/*.xcodeproj/project.pbxproj",
79
+ "ios/**/*.xcodeproj/xcshareddata/xcschemes/*.xcscheme",
80
+ "ios/**/*.xcworkspace/contents.xcworkspacedata",
81
+ // macOS
82
+ "macos/Podfile",
83
+ "macos/**/*.xcodeproj/project.pbxproj",
84
+ "macos/**/*.xcodeproj/xcshareddata/xcschemes/*.xcscheme",
85
+ "macos/**/*.xcworkspace/contents.xcworkspacedata",
86
+ // Windows
87
+ "windows/**/*.sln",
88
+ "windows/**/*.vcxproj",
89
+ "windows/**/*.vcxproj.filters",
90
+ "windows/**/*.vcxitems",
91
+ "windows/**/*.vcxitems.filters",
92
+ "windows/**/*.props",
93
+ "windows/**/*.targets",
94
+ "windows/**/*.h",
95
+ "windows/**/*.hpp",
96
+ "windows/**/*.c",
97
+ "windows/**/*.cpp",
98
+ "windows/**/*.idl",
99
+ "windows/**/*.rc",
100
+ "windows/**/*.xml",
101
+ "windows/**/*.xaml",
102
+ "windows/**/*.appxmanifest",
103
+ ];
104
+ export async function renameTemplateAppNameAsync(cwd, { filesafeName, files, }) {
105
+ if (!files.length) {
106
+ return;
107
+ }
108
+ await Promise.all(files.map(async (file) => {
109
+ const absoluteFilePath = path.resolve(cwd, file);
110
+ let contents;
111
+ try {
112
+ contents = await fs.promises.readFile(absoluteFilePath, { encoding: "utf-8" });
113
+ }
114
+ catch (cause) {
115
+ throw new Error(`Failed to read template file: "${absoluteFilePath}". Was it removed mid-operation?`, { cause });
116
+ }
117
+ const safeName = [".xml", ".plist", ".xaml", ".appxmanifest"].includes(path.extname(file))
118
+ ? escapeXMLCharacters(filesafeName)
119
+ : filesafeName;
120
+ try {
121
+ const replacement = contents
122
+ .replace(/Hello App Display Name/g, safeName)
123
+ .replace(/HelloWorld/g, XcodeUtils.sanitizedName(safeName))
124
+ .replace(/helloworld/g, XcodeUtils.sanitizedName(safeName.toLowerCase()));
125
+ if (replacement === contents) {
126
+ return;
127
+ }
128
+ await fs.promises.writeFile(absoluteFilePath, replacement);
129
+ }
130
+ catch (cause) {
131
+ throw new Error(`Failed to overwrite template file: "${absoluteFilePath}". Was it removed mid-operation?`, { cause });
132
+ }
133
+ }));
134
+ }
135
+ function escapeXMLCharacters(original) {
136
+ const noAmps = original.replace("&", "&amp;");
137
+ const noLt = noAmps.replace("<", "<");
138
+ const noGt = noLt.replace(">", ">");
139
+ const noApos = noGt.replace('"', '\\"');
140
+ return noApos.replace("'", "\\'");
141
+ }
@@ -0,0 +1,265 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { tasks } from "@clack/prompts";
10
+ import fs from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { promisifiedSpawnTask } from "./child-process.js";
15
+ import { getTemplateFilesToRenameAsync, renameTemplateAppNameAsync, } from "./rename-template-app-name.js";
16
+ export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, }) {
17
+ const descriptors = getOrderedTemplateDescriptors(selection, enabledPlatforms);
18
+ if (!descriptors.length) {
19
+ return;
20
+ }
21
+ // Post-process the templates just like the `react-native-macos-init` and
22
+ // `react-native init-windows` commands do:
23
+ //
24
+ // macos:
25
+ // - https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native-macos-init/src/cli.ts#L398
26
+ // - https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native/local-cli/generate-macos.js#L18
27
+ // - https://github.com/microsoft/react-native-macos/tree/main/packages/react-native/local-cli/generator-macos/templates/macos
28
+ //
29
+ // windows:
30
+ // - https://github.com/microsoft/react-native-windows/blob/3d64f71ed8495da6a0dcfc1f97bcb8f761986594/packages/%40react-native-windows/cli/src/generator-windows/index.ts#L57
31
+ // - https://github.com/microsoft/react-native-windows/tree/main/vnext/templates/cpp-app
32
+ for (const descriptor of descriptors) {
33
+ const source = parseTemplateSource(descriptor.value);
34
+ const extracted = await prepareTemplateSourceAsync(source);
35
+ try {
36
+ const templateRoot = await resolveTemplateRootAsync(extracted, source);
37
+ const templateConfig = await loadTemplateConfigAsync(templateRoot);
38
+ await copyTemplateFilesAsync({
39
+ sourceRoot: templateRoot,
40
+ projectRoot,
41
+ name,
42
+ templateConfig,
43
+ });
44
+ }
45
+ finally {
46
+ await fs.rm(extracted, { recursive: true, force: true });
47
+ }
48
+ }
49
+ }
50
+ function getOrderedTemplateDescriptors(selection, enabledPlatforms) {
51
+ const platformSet = new Set(enabledPlatforms);
52
+ const descriptors = new Array();
53
+ if (selection.template) {
54
+ descriptors.push({ key: "template", value: selection.template });
55
+ }
56
+ if (selection["template-ios"] && platformSet.has("ios")) {
57
+ descriptors.push({
58
+ key: "template-ios",
59
+ value: selection["template-ios"],
60
+ forPlatform: "ios",
61
+ });
62
+ }
63
+ if (selection["template-android"] && platformSet.has("android")) {
64
+ descriptors.push({
65
+ key: "template-android",
66
+ value: selection["template-android"],
67
+ forPlatform: "android",
68
+ });
69
+ }
70
+ if (selection["template-macos"] && platformSet.has("macos")) {
71
+ descriptors.push({
72
+ key: "template-macos",
73
+ value: selection["template-macos"],
74
+ forPlatform: "macos",
75
+ });
76
+ }
77
+ if (selection["template-windows"] && platformSet.has("windows")) {
78
+ descriptors.push({
79
+ key: "template-windows",
80
+ value: selection["template-windows"],
81
+ forPlatform: "windows",
82
+ });
83
+ }
84
+ return descriptors;
85
+ }
86
+ function parseTemplateSource(template) {
87
+ const localPath = path.resolve(process.cwd(), template);
88
+ if (/\.(?:tar|tgz|tar\.gz)$/i.test(template)) {
89
+ return { type: "local-tarball", path: localPath };
90
+ }
91
+ const githubUrlMatch = template.match(/^https:\/\/github\.com\/([^/]+)\/([^/#]+?)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?$/);
92
+ if (githubUrlMatch) {
93
+ const [, owner, repo, ref = "HEAD", subpath] = githubUrlMatch;
94
+ return { type: "github", owner, repo, ref, subpath: subpath ?? null };
95
+ }
96
+ const githubShorthandMatch = template.match(/^([^/\s#]+)\/([^/\s#]+)(?:#(.+))?$/);
97
+ if (githubShorthandMatch) {
98
+ const [, owner, repo, rawRef] = githubShorthandMatch;
99
+ const [ref, ...subpathParts] = (rawRef ?? "HEAD").split(":");
100
+ return {
101
+ type: "github",
102
+ owner,
103
+ repo,
104
+ ref,
105
+ subpath: subpathParts.length ? subpathParts.join(":") : null,
106
+ };
107
+ }
108
+ return { type: "npm", spec: template };
109
+ }
110
+ async function prepareTemplateSourceAsync(source) {
111
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "expo-desktop-template-"));
112
+ const archivePath = path.join(tempRoot, "template.tgz");
113
+ switch (source.type) {
114
+ case "local-tarball":
115
+ await fs.copyFile(source.path, archivePath);
116
+ break;
117
+ case "github": {
118
+ const tarballUrl = `https://codeload.github.com/${source.owner}/${source.repo}/tar.gz/${source.ref}`;
119
+ const response = await fetch(tarballUrl);
120
+ if (!response.ok || !response.body) {
121
+ throw new Error(`Failed to download template tarball from ${tarballUrl}`);
122
+ }
123
+ const bytes = new Uint8Array(await response.arrayBuffer());
124
+ await fs.writeFile(archivePath, bytes);
125
+ break;
126
+ }
127
+ case "npm": {
128
+ await tasks([
129
+ promisifiedSpawnTask({
130
+ title: `npm pack (${source.spec})`,
131
+ command: "npm",
132
+ args: ["pack", source.spec, "--silent"],
133
+ options: { cwd: tempRoot },
134
+ }),
135
+ ]);
136
+ const entries = await fs.readdir(tempRoot);
137
+ const packed = entries.find((entry) => entry.endsWith(".tgz"));
138
+ if (!packed) {
139
+ throw new Error(`Could not pack template "${source.spec}".`);
140
+ }
141
+ await fs.rename(path.join(tempRoot, packed), archivePath);
142
+ break;
143
+ }
144
+ }
145
+ await tasks([
146
+ promisifiedSpawnTask({
147
+ title: "extracting template",
148
+ command: "tar",
149
+ args: ["-xzf", archivePath, "-C", tempRoot],
150
+ }),
151
+ ]);
152
+ return tempRoot;
153
+ }
154
+ async function resolveTemplateRootAsync(extractedRoot, source) {
155
+ const entries = await fs.readdir(extractedRoot, { withFileTypes: true });
156
+ const firstDir = entries.find((entry) => entry.isDirectory() && entry.name !== ".git");
157
+ if (!firstDir) {
158
+ throw new Error("Extracted template archive did not contain a root directory.");
159
+ }
160
+ let templateRoot = path.join(extractedRoot, firstDir.name);
161
+ if (source.type === "npm") {
162
+ templateRoot = path.join(templateRoot, "package");
163
+ }
164
+ if (source.type === "github" && source.subpath) {
165
+ templateRoot = path.join(templateRoot, source.subpath);
166
+ }
167
+ return templateRoot;
168
+ }
169
+ async function loadTemplateConfigAsync(templateRoot) {
170
+ const configPath = path.join(templateRoot, "template.config.js");
171
+ try {
172
+ await fs.access(configPath);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ const imported = (await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href)));
178
+ return imported.default ?? imported;
179
+ }
180
+ async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateConfig, }) {
181
+ const mappings = templateConfig?.files?.length
182
+ ? templateConfig.files.map((mapping) => ({
183
+ from: path.join(sourceRoot, mapping.from),
184
+ to: mapping.to ?? mapping.from,
185
+ }))
186
+ : await discoverAllFilesAsync(sourceRoot);
187
+ const pathReplacements = {
188
+ HelloWorld: name.filesafeName,
189
+ helloworld: name.filesafeName.toLowerCase(),
190
+ ...(templateConfig?.pathReplacements ?? {}),
191
+ };
192
+ const copiedRelativePaths = new Array();
193
+ for (const mapping of mappings) {
194
+ const relativePath = replaceTokens(mapping.to, pathReplacements);
195
+ const targetPath = path.join(projectRoot, relativePath);
196
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
197
+ await fs.copyFile(mapping.from, targetPath);
198
+ copiedRelativePaths.push(relativePath);
199
+ }
200
+ if (templateConfig?.replacements && Object.keys(templateConfig.replacements).length > 0) {
201
+ await applyExtraReplacementsAsync({
202
+ cwd: projectRoot,
203
+ files: copiedRelativePaths,
204
+ replacements: templateConfig.replacements,
205
+ });
206
+ }
207
+ const filesFromRenameConfig = await getTemplateFilesToRenameAsync(projectRoot, {
208
+ renameConfig: templateConfig?.renameConfig,
209
+ });
210
+ const copiedSet = new Set(copiedRelativePaths.map(normalizeToPosixPath));
211
+ const filesToRename = filesFromRenameConfig.filter((file) => copiedSet.has(normalizeToPosixPath(file)));
212
+ await renameTemplateAppNameAsync(projectRoot, {
213
+ filesafeName: name.filesafeName,
214
+ files: filesToRename,
215
+ });
216
+ }
217
+ async function discoverAllFilesAsync(sourceRoot) {
218
+ const out = new Array();
219
+ await walkFilesAsync(sourceRoot, sourceRoot, out);
220
+ return out.filter((entry) => path.basename(entry.to) !== "template.config.js");
221
+ }
222
+ async function walkFilesAsync(currentDir, sourceRoot, output) {
223
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ const absolute = path.join(currentDir, entry.name);
226
+ if (entry.isDirectory()) {
227
+ await walkFilesAsync(absolute, sourceRoot, output);
228
+ continue;
229
+ }
230
+ if (!entry.isFile()) {
231
+ continue;
232
+ }
233
+ output.push({ from: absolute, to: path.relative(sourceRoot, absolute) });
234
+ }
235
+ }
236
+ function replaceTokens(input, replacements) {
237
+ let output = input;
238
+ for (const [from, to] of Object.entries(replacements)) {
239
+ output = output.split(from).join(to);
240
+ }
241
+ return output;
242
+ }
243
+ async function applyExtraReplacementsAsync({ cwd, files, replacements, }) {
244
+ for (const file of files) {
245
+ const absolute = path.join(cwd, file);
246
+ let contents;
247
+ try {
248
+ contents = await fs.readFile(absolute, "utf8");
249
+ }
250
+ catch (error) {
251
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
252
+ throw error;
253
+ }
254
+ continue;
255
+ }
256
+ const replacement = replaceTokens(contents, replacements);
257
+ if (replacement === contents) {
258
+ continue;
259
+ }
260
+ await fs.writeFile(absolute, replacement, "utf8");
261
+ }
262
+ }
263
+ function normalizeToPosixPath(input) {
264
+ return input.replaceAll(path.sep, "/");
265
+ }
@@ -17,6 +17,13 @@ export async function newExpoDesktopProject(args) {
17
17
  rdns: "com.example.my-app-123",
18
18
  },
19
19
  packageManager: "bun",
20
+ templates: {
21
+ template: args.template,
22
+ "template-ios": args["template-ios"],
23
+ "template-android": args["template-android"],
24
+ "template-macos": args["template-macos"],
25
+ "template-windows": args["template-windows"],
26
+ },
20
27
  versions: {
21
28
  expoMajor: 54,
22
29
  expoBlankTypeScript: "54.0.45",
@@ -49,7 +56,18 @@ export async function newExpoDesktopProject(args) {
49
56
  if (isCancel(packageManager)) {
50
57
  process.exit(0);
51
58
  }
52
- await createExpoDesktopApp({ name, packageManager, versions });
59
+ await createExpoDesktopApp({
60
+ name,
61
+ packageManager,
62
+ versions,
63
+ templates: {
64
+ template: args.template,
65
+ "template-ios": args["template-ios"],
66
+ "template-android": args["template-android"],
67
+ "template-macos": args["template-macos"],
68
+ "template-windows": args["template-windows"],
69
+ },
70
+ });
53
71
  }
54
72
  async function configureAppName(args) {
55
73
  const { initialFilesafeName, initialDisplayName, initialRdns } = args;
@@ -11,6 +11,7 @@ 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 { applySelectedTemplatesAsync } from "../common/template.js";
14
15
  /**
15
16
  * A crude switch to use to help with local development.
16
17
  *
@@ -20,9 +21,29 @@ import { packageManagerExec } from "../common/npm.js";
20
21
  * - Adds the apply-config-plugins.mjs script.
21
22
  */
22
23
  export const localDev = false;
23
- export async function createExpoDesktopApp({ name, packageManager, versions, }) {
24
+ export async function createExpoDesktopApp({ name, packageManager, templates, versions, }) {
24
25
  const { projectPath } = await createExpoApp({ name, packageManager, versions });
25
26
  await appendRootGitignoreSpawnDebugLogs(projectPath);
27
+ const templateSelection = {
28
+ // https://github.com/expo/expo/blob/sdk-54/templates/expo-template-blank-typescript
29
+ template: templates.template,
30
+ "template-ios": templates["template-ios"],
31
+ "template-android": templates["template-android"],
32
+ // https://github.com/microsoft/react-native-macos/tree/main/packages/react-native/local-cli/generator-macos/templates/macos
33
+ "template-macos": templates["template-macos"] ??
34
+ "microsoft/react-native-macos#main:packages/react-native/local-cli/generator-macos/templates",
35
+ // https://github.com/microsoft/react-native-windows/tree/main/vnext/templates/cpp-app
36
+ "template-windows": templates["template-windows"] ??
37
+ "microsoft/react-native-windows#main:vnext/templates/cpp-app",
38
+ };
39
+ title("Applying templates…", { spacing: 1 });
40
+ await applySelectedTemplatesAsync({
41
+ projectRoot: projectPath,
42
+ selection: templateSelection,
43
+ enabledPlatforms: ["ios", "android", "macos", "windows"],
44
+ name,
45
+ });
46
+ console.log(`${green("◆")} Applied templates.\n`);
26
47
  title("Altering app.json…", { spacing: 1 });
27
48
  await updateAppJson({ name, projectPath });
28
49
  title("Altering package.json…", { spacing: 1 });
@@ -34,11 +55,6 @@ export async function createExpoDesktopApp({ name, packageManager, versions, })
34
55
  });
35
56
  title("Installing dependencies…", { spacing: 1 });
36
57
  await npmInstall({ cwd: projectPath, packageManager });
37
- title("Adding the Windows app…", { spacing: 1 });
38
- await addDesktopApp({ cwd: projectPath, name, packageManager, type: "windows", versions });
39
- title("Adding the macOS app…", { spacing: 1 });
40
- await updatePackageJson({ name, projectPath, versions, task: { type: "pre-init-macos" } });
41
- await addDesktopApp({ cwd: projectPath, name, packageManager, type: "macos", versions });
42
58
  await updatePackageJson({
43
59
  name,
44
60
  projectPath,
@@ -201,23 +217,7 @@ async function updatePackageJson({ name, projectPath, task, versions, }) {
201
217
  throw new Error(`Invalid config:\n${makePrettySummary(packageJson).join("\n")}`);
202
218
  }
203
219
  const nameBefore = packageJson.name;
204
- if (task.type === "pre-init-macos") {
205
- // create-expo-app shifts this to lowercase as per package.json rules, and
206
- // then react-native-macos-init maddeningly uses it in preference over the
207
- // app.json "name" value.
208
- // https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native-macos-init/src/cli.ts#L74-L75
209
- //
210
- // If only the underlying generateMacOS() / copyProjectTemplateAndReplace()
211
- // were exposed, we could just pass the name needed.
212
- // https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native-macos-init/src/cli.ts#L398
213
- // https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native/local-cli/generate-macos.js#L18
214
- //
215
- // But as it's not, our best option is to just write an invalid name into
216
- // the package.json temporarily (or remove it altogether). We'll set it
217
- // back to lower case later in the "restore-name" task.
218
- packageJson.name = name.filesafeName;
219
- }
220
- else if (task.type === "post-init-macos") {
220
+ if (task.type === "post-init-macos") {
221
221
  if (task.name) {
222
222
  packageJson.name = task.name;
223
223
  }
@@ -295,36 +295,6 @@ async function npmInstall({ cwd, packageManager, }) {
295
295
  }
296
296
  console.log(`\n${green("◆")} Installed dependencies.\n`);
297
297
  }
298
- async function addDesktopApp({ name, packageManager, versions, type, cwd, }) {
299
- const { args, command } = packageManagerExec(packageManager);
300
- switch (type) {
301
- case "macos":
302
- args.push("react-native-macos-init", "--version", versions.macos);
303
- break;
304
- case "windows":
305
- args.push("react-native", "init-windows", "--template", "cpp-app", "--namespace", name.rdns.replaceAll(/[-_]/g, ""), "--name", name.filesafeName);
306
- break;
307
- }
308
- const printedCommand = `${command} ${args.join(" ")}`;
309
- console.log(`${cyan("◆")} Running: ${yellow(printedCommand)}\n`);
310
- try {
311
- await tasks([
312
- promisifiedSpawnTask({
313
- title: type === "macos"
314
- ? "react-native-macos-init"
315
- : `react-native init-windows (${name.filesafeName})`,
316
- command,
317
- args,
318
- options: { cwd, stdio: "inherit" },
319
- }),
320
- ]);
321
- }
322
- catch (error) {
323
- log.error(`Error running ${yellow(printedCommand)}${error instanceof Error ? `: ${error.message}` : "."}`);
324
- process.exit(1);
325
- }
326
- console.log(`${green("◆")} Added ${yellow(type)} app.\n`);
327
- }
328
298
  async function runPrebuildMobile({ packageManager, projectPath, }) {
329
299
  const { args, command } = packageManagerExec(packageManager);
330
300
  args.push("expo", "prebuild", "--no-install");
@@ -1,6 +1,11 @@
1
1
  import { log } from "@clack/prompts";
2
+ import { type } from "arktype";
2
3
  import { default as kleur } from "kleur";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
3
6
  import { exit } from "node:process";
7
+ import { AppJson } from "../common/app-json.js";
8
+ import { applySelectedTemplatesAsync } from "../common/template.js";
4
9
  import { resolvePackageManagerOptions } from "./resolve-options.js";
5
10
  /**
6
11
  * The entrypoint for `npx expo prebuild` is here:
@@ -12,21 +17,10 @@ import { resolvePackageManagerOptions } from "./resolve-options.js";
12
17
  * @see https://github.com/expo/expo/blob/15d35298c9a397c23bcbf6b20e2b9761564acbc4/packages/%40expo/cli/src/prebuild/index.ts#L7
13
18
  * @see https://github.com/expo/expo/blob/15d35298c9a397c23bcbf6b20e2b9761564acbc4/packages/%40expo/cli/src/prebuild/configureProjectAsync.ts#L37
14
19
  */
15
- export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun, pnpm, template, platform, }) {
20
+ export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun, pnpm, template, "template-ios": templateIos, "template-android": templateAndroid, "template-macos": templateMacos, "template-windows": templateWindows, platform, }) {
16
21
  log.info(`🏎️ Running ${kleur.yellow("expo-desktop prebuild")}.`, { withGuide: false });
17
22
  // TODO: if packageManager undefined, infer from lockfiles
18
23
  const _packageManager = resolvePackageManagerOptions({ noInstall, npm, yarn, bun, pnpm });
19
- if (template) {
20
- // macos:
21
- // - https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native-macos-init/src/cli.ts#L398
22
- // - https://github.com/microsoft/react-native-macos/blob/eb3bccb6e738650d617945770ec1319d5880084b/packages/react-native/local-cli/generate-macos.js#L18
23
- // - https://github.com/microsoft/react-native-macos/tree/main/packages/react-native/local-cli/generator-macos/templates/macos
24
- //
25
- // windows:
26
- // - https://github.com/microsoft/react-native-windows/blob/3d64f71ed8495da6a0dcfc1f97bcb8f761986594/packages/%40react-native-windows/cli/src/generator-windows/index.ts#L57
27
- // - https://github.com/microsoft/react-native-windows/tree/main/vnext/templates/cpp-app
28
- throw new Error("--template arg not yet implemented.");
29
- }
30
24
  if (platform !== "macos" &&
31
25
  platform !== "windows" &&
32
26
  platform !== "desktop" &&
@@ -43,6 +37,24 @@ export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun,
43
37
  if (!platforms.length) {
44
38
  throw new Error("At least one platform must be enabled when syncing");
45
39
  }
40
+ const templateSelection = {
41
+ template,
42
+ "template-ios": templateIos,
43
+ "template-android": templateAndroid,
44
+ "template-macos": templateMacos,
45
+ "template-windows": templateWindows,
46
+ };
47
+ if (clean && hasTemplateSelection(templateSelection)) {
48
+ const projectRoot = process.cwd();
49
+ const appName = await readAppNameFromConfigAsync(projectRoot);
50
+ await applySelectedTemplatesAsync({
51
+ projectRoot,
52
+ selection: templateSelection,
53
+ enabledPlatforms: platforms,
54
+ name: appName,
55
+ });
56
+ log.info("Applied project templates for clean prebuild.", { withGuide: false });
57
+ }
46
58
  // TODO:
47
59
  // - prebuildAsync()
48
60
  // - https://github.com/expo/expo/blob/8dd645080f52927e2a8bf406167da7241a1d46d8/packages/%40expo/cli/src/prebuild/prebuildAsync.ts#L49
@@ -67,3 +79,26 @@ export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun,
67
79
  log.error(`${kleur.yellow("expo-desktop prebuild")} not yet implemented.`);
68
80
  return exit(1);
69
81
  }
82
+ function hasTemplateSelection(selection) {
83
+ return Boolean(selection.template ||
84
+ selection["template-ios"] ||
85
+ selection["template-android"] ||
86
+ selection["template-macos"] ||
87
+ selection["template-windows"]);
88
+ }
89
+ async function readAppNameFromConfigAsync(projectRoot) {
90
+ const appJsonPath = path.join(projectRoot, "app.json");
91
+ const contents = await fs.readFile(appJsonPath, "utf8");
92
+ const parsed = AppJson(JSON.parse(contents));
93
+ if (parsed instanceof type.errors) {
94
+ throw new Error("Invalid app.json while resolving template replacements.");
95
+ }
96
+ const filesafeName = parsed.expo?.name ?? "HelloWorld";
97
+ const displayName = parsed.expo?.name ?? filesafeName;
98
+ const rdns = parsed.expo?.ios?.bundleIdentifier ?? parsed.expo?.android?.package ?? "com.helloworld";
99
+ return {
100
+ filesafeName,
101
+ displayName,
102
+ rdns,
103
+ };
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-desktop",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Best-effort desktop support for Expo",
5
5
  "keywords": [
6
6
  "android",
@@ -37,10 +37,11 @@
37
37
  "@clack/prompts": "^1.2.0",
38
38
  "arktype": "^2.2.0",
39
39
  "citty": "^0.2.2",
40
+ "glob": "^10.5.0",
40
41
  "kleur": "^4.1.5",
41
42
  "toml": "^4.1.1",
42
- "expo-desktop-config-plugins": "1.1.16",
43
- "expo-desktop-prebuild-config": "1.0.5"
43
+ "expo-desktop-config-plugins": "1.1.17",
44
+ "expo-desktop-prebuild-config": "1.0.6"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@expo/config": "^12.0.13",