@ng-native/nx 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @ng-native/nx
2
2
 
3
- Nx generators for Angular Native: `nx add @ng-native/nx`, and an app generator that adds an Expo
4
- app project to the workspace, able to import its Angular libraries.
3
+ Nx generators for Angular Native: `nx add @ng-native/nx`, an app generator that adds an Expo app
4
+ project to the workspace, able to import its Angular libraries, and library and component
5
+ generators whose tests render on the fake Fabric.
5
6
 
6
7
  Alpha: APIs may change before 1.0.
7
8
 
@@ -10,6 +11,7 @@ Alpha: APIs may change before 1.0.
10
11
  ```sh
11
12
  nx add @ng-native/nx
12
13
  nx g @ng-native/nx:app apps/mobile
14
+ nx g @ng-native/nx:library packages/ui
13
15
  ```
14
16
 
15
17
  `nx add` adds `@nx/expo` at the workspace's Nx version and registers its plugin, which infers the
@@ -30,7 +32,10 @@ In a workspace whose root package is scoped, the project is `@org/mobile`.
30
32
 
31
33
  ## What's in the package
32
34
 
33
- - `generators.json` - `init`, which `nx add` runs, and `application` (alias `app`).
35
+ - `generators.json` - `init`, which `nx add` runs, `application` (alias `app`), `library` (alias
36
+ `lib`), `component` (alias `c`), `tailwind`, which sets up Tailwind in an app and the libraries
37
+ it uses, and `sync-native-modules`, the sync generator an app runs before `start`, `export` and
38
+ `prebuild`.
34
39
  - `files/` - the template's source files, which a test keeps identical to `template/`.
35
40
 
36
41
  ## Docs
@@ -18,6 +18,7 @@
18
18
  const path = require('node:path').posix;
19
19
  const {
20
20
  formatFiles,
21
+ getProjects,
21
22
  joinPathFragments,
22
23
  logger,
23
24
  offsetFromRoot,
@@ -31,6 +32,7 @@ const native = require('../native-app.cjs');
31
32
  const { init, workspaceNxVersion } = require('../init/index.cjs');
32
33
  const { usesWorkspaces, includeInWorkspaces } = require('./workspaces.cjs');
33
34
  const { asSaved } = require('../save-exact.cjs');
35
+ const { registerSyncGenerator } = require('../sync/index.cjs');
34
36
 
35
37
  function names(tree, options) {
36
38
  const directory = path.normalize(options.directory).replace(/\/$/, '');
@@ -85,17 +87,56 @@ function extendedFile(tree, directory, parent) {
85
87
  }
86
88
  }
87
89
 
88
- function targets(directory) {
90
+ /** Expo's own, which `expo start` listens on unless given `--port`. */
91
+ const DEFAULT_PORT = 8081;
92
+
93
+ /**
94
+ * The lowest Metro port no other Expo app in the workspace listens on, so that
95
+ * `nx run-many -t start` can run them together: two on Expo's default raced for it, and the second
96
+ * died with `EADDRINUSE`. Each `expo start` a target runs, as `command` or in `commands`, takes the
97
+ * port its `--port` names, or Expo's default without one. An app with an `app.json` and no such
98
+ * command has the default too: its `start` is inferred.
99
+ */
100
+ function metroPort(tree) {
101
+ const taken = new Set();
102
+ for (const [, project] of getProjects(tree)) {
103
+ const starts = Object.values(project.targets ?? {})
104
+ .flatMap(({ options }) => [options?.command, ...(options?.commands ?? [])])
105
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.command))
106
+ .filter((command) => typeof command === 'string' && /\bexpo start\b/.test(command));
107
+ for (const command of starts) {
108
+ taken.add(Number(command.match(/--port[= ](\d+)/)?.[1] ?? DEFAULT_PORT));
109
+ }
110
+ if (!starts.length && tree.exists(joinPathFragments(project.root, 'app.json'))) {
111
+ taken.add(DEFAULT_PORT);
112
+ }
113
+ }
114
+ let port = DEFAULT_PORT;
115
+ while (taken.has(port)) port++;
116
+ return port;
117
+ }
118
+
119
+ function targets(directory, port) {
89
120
  const run = (command) => ({ executor: 'nx:run-commands', options: { cwd: directory, command } });
121
+ const own = port === DEFAULT_PORT ? '' : ` --port ${port}`;
122
+ // `expo run:ios` and `expo run:android` build the port into the app, so a later app's has to be
123
+ // its own too. The first app's are @nx/expo's inferred ones, on Expo's default.
124
+ const runs = own && {
125
+ 'run-ios': { ...run(`expo run:ios${own}`), continuous: true },
126
+ 'run-android': { ...run(`expo run:android${own}`), continuous: true },
127
+ };
90
128
  return {
91
129
  typecheck: {
92
- ...run('ngc -p tsconfig.json --noEmit'),
130
+ ...run(native.TYPECHECK),
93
131
  cache: true,
94
132
  inputs: ['default', '^production'],
133
+ // The Tailwind sheet the command builds, which a cache hit restores rather than rebuilds.
134
+ outputs: ['{projectRoot}/.angular-native'],
95
135
  },
96
136
  test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
97
- start: { ...run('expo start'), continuous: true },
98
- serve: { ...run('expo start'), continuous: true },
137
+ start: { ...run(`expo start${own}`), continuous: true },
138
+ serve: { ...run(`expo start${own}`), continuous: true },
139
+ ...runs,
99
140
  };
100
141
  }
101
142
 
@@ -143,8 +184,7 @@ async function writeFiles(tree, { directory, projectName, workspaces, bundleIden
143
184
  file('AGENTS.md', native.agentsFile(commands(projectName)));
144
185
  file('CLAUDE.md', '@AGENTS.md\n');
145
186
  file('metro.config.js', native.METRO_CONFIG);
146
- // The template's own, for the native projects `expo prebuild` writes beside app.json.
147
- file('.gitignore', '# generated native folders\n/ios\n/android\n');
187
+ file('.gitignore', native.GITIGNORE);
148
188
 
149
189
  const base = hasPathAliases(tree, workspaces);
150
190
  const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
@@ -181,16 +221,13 @@ function ignoreExpo(tree) {
181
221
  async function application(tree, options) {
182
222
  const resolved = names(tree, options);
183
223
  const { directory, projectName, workspaces } = resolved;
184
- if (!native.isBundleIdentifier(resolved.bundleIdentifier)) {
185
- throw new Error(
186
- `${resolved.bundleIdentifier} is not a bundle identifier both iOS and Android accept: ` +
187
- 'two or more dot-separated segments, each a letter and then letters or digits, none a Java keyword.',
188
- );
189
- }
224
+ const problem = native.bundleIdentifierProblem(resolved.bundleIdentifier);
225
+ if (problem) throw new Error(problem);
190
226
  if (tree.exists(joinPathFragments(directory, 'package.json'))) {
191
227
  throw new Error(`${directory} already has a package.json. Choose another directory.`);
192
228
  }
193
229
  const initTask = await init(tree, { skipInstall: true, skipFormat: true });
230
+ const port = metroPort(tree);
194
231
 
195
232
  if (workspaces) {
196
233
  includeInWorkspaces(tree, directory);
@@ -221,10 +258,11 @@ async function application(tree, options) {
221
258
  .split(',')
222
259
  .map((tag) => tag.trim())
223
260
  .filter(Boolean),
224
- targets: targets(directory),
261
+ targets: targets(directory, port),
225
262
  });
226
263
 
227
264
  ignoreExpo(tree);
265
+ registerSyncGenerator(tree, projectName);
228
266
 
229
267
  if (!options.skipFormat) await formatFiles(tree);
230
268
  logger.info(
@@ -235,4 +273,4 @@ async function application(tree, options) {
235
273
  return runTasksInSerial(initTask, () => installPackagesTask(tree, true));
236
274
  }
237
275
 
238
- module.exports = { application };
276
+ module.exports = { application, hasPathAliases };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `nx g @ng-native/nx:component profile-card --project ui`: a component for an Angular Native app
3
+ * or library.
4
+ *
5
+ * `@nx/angular:component` writes a `<p>` and a `TestBed` spec, neither of which works on a device
6
+ * or on the fake Fabric. This writes what `@ng-native/schematics`' component schematic writes for
7
+ * the Angular CLI: a component built from `<view>` and `<text>`, with an inline template, and a
8
+ * `.test.ts` beside it that renders it. It goes under `src/lib` in a library and `src/app` in an
9
+ * app, in a folder of its own unless `--flat`.
10
+ */
11
+ const path = require('node:path').posix;
12
+ const { formatFiles, getProjects, joinPathFragments, names } = require('@nx/devkit');
13
+ const { componentSource, testSource } = require('./sources.cjs');
14
+
15
+ /**
16
+ * Whether the project is an app: by its `projectType`, or, where it has none, as `@nx/js:library`
17
+ * leaves a package.json-only project in the TypeScript preset, by the `app.json` an Expo app has.
18
+ *
19
+ * @param {import('@nx/devkit').Tree} tree
20
+ * @param {import('@nx/devkit').ProjectConfiguration} project
21
+ */
22
+ function isApp(tree, project) {
23
+ if (project.projectType) return project.projectType === 'application';
24
+ return tree.exists(joinPathFragments(project.root, 'app.json'));
25
+ }
26
+
27
+ /**
28
+ * @param {import('@nx/devkit').Tree} tree
29
+ * @param {{ name: string, project: string, flat?: boolean, skipTests?: boolean, skipFormat?: boolean }} options
30
+ */
31
+ async function component(tree, options) {
32
+ const project = getProjects(tree).get(options.project);
33
+ if (!project) throw new Error(`The workspace has no project called "${options.project}".`);
34
+ const file = names(path.basename(options.name)).fileName;
35
+ const base = joinPathFragments(
36
+ project.root,
37
+ 'src',
38
+ isApp(tree, project) ? 'app' : 'lib',
39
+ path.dirname(options.name),
40
+ );
41
+ const folder = options.flat ? base : joinPathFragments(base, file);
42
+ writeComponent(tree, folder, file, project.prefix, options.skipTests);
43
+ if (!options.skipFormat) await formatFiles(tree);
44
+ }
45
+
46
+ /**
47
+ * The component `file` in `folder`, and its test unless `skipTests`. The library generator writes
48
+ * its first component with this too.
49
+ *
50
+ * @param {import('@nx/devkit').Tree} tree
51
+ * @param {string} folder
52
+ * @param {string} file the file name, kebab-case, without `.ts`
53
+ * @param {string | undefined} prefix the project's selector prefix
54
+ * @param {boolean} [skipTests]
55
+ */
56
+ function writeComponent(tree, folder, file, prefix, skipTests = false) {
57
+ const className = names(file).className;
58
+ const selector = prefix ? `${prefix}-${file}` : file;
59
+ const label = `${file} works`;
60
+ tree.write(joinPathFragments(folder, `${file}.ts`), componentSource(className, selector, label));
61
+ if (!skipTests) {
62
+ tree.write(joinPathFragments(folder, `${file}.test.ts`), testSource(className, file, label));
63
+ }
64
+ }
65
+
66
+ module.exports = { component, writeComponent };
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://json-schema.org/schema",
3
+ "$id": "NgNativeNxComponent",
4
+ "title": "Angular Native component",
5
+ "description": "A component for an Angular Native app or library, with native elements and a test that runs in Node.",
6
+ "type": "object",
7
+ "properties": {
8
+ "name": {
9
+ "type": "string",
10
+ "description": "The component's name, optionally with a path: settings/profile-card.",
11
+ "$default": { "$source": "argv", "index": 0 },
12
+ "x-prompt": "What should the component be called?"
13
+ },
14
+ "project": {
15
+ "type": "string",
16
+ "description": "The app or library it belongs to.",
17
+ "$default": { "$source": "projectName" },
18
+ "x-dropdown": "projects"
19
+ },
20
+ "flat": {
21
+ "type": "boolean",
22
+ "description": "Create the files in the directory itself rather than in a folder of their own.",
23
+ "default": false
24
+ },
25
+ "skipTests": {
26
+ "type": "boolean",
27
+ "description": "Leave out the .test.ts file.",
28
+ "default": false
29
+ },
30
+ "skipFormat": {
31
+ "type": "boolean",
32
+ "description": "Leave the generated files unformatted.",
33
+ "default": false,
34
+ "x-priority": "internal"
35
+ }
36
+ },
37
+ "required": ["name", "project"]
38
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The files a generated component is made of: a component built from `<view>` and `<text>`, and a
3
+ * test that renders it on the fake Fabric. `@ng-native/schematics` and `@ng-native/nx` each ship
4
+ * this file, and a test in `@ng-native/nx` keeps the two identical.
5
+ */
6
+
7
+ /**
8
+ * @param {string} className
9
+ * @param {string} selector
10
+ * @param {string} label the text it shows, which its test finds
11
+ */
12
+ function componentSource(className, selector, label) {
13
+ return `import { Component } from '@angular/core';
14
+ import { Text, View } from '@ng-native/components';
15
+
16
+ @Component({
17
+ selector: '${selector}',
18
+ imports: [Text, View],
19
+ template: \`
20
+ <view>
21
+ <text>${label}</text>
22
+ </view>
23
+ \`,
24
+ })
25
+ export class ${className} {}
26
+ `;
27
+ }
28
+
29
+ /**
30
+ * @param {string} className
31
+ * @param {string} file the component's file name, without `.ts`
32
+ * @param {string} label
33
+ */
34
+ function testSource(className, file, label) {
35
+ return `import { render, screen } from '@ng-native/testing';
36
+ import { expect, test } from 'vitest';
37
+ import { ${className} } from './${file}.ts';
38
+
39
+ test('renders', async () => {
40
+ await render(${className});
41
+
42
+ expect(screen.getByText('${label}')).toBeTruthy();
43
+ });
44
+ `;
45
+ }
46
+
47
+ module.exports = { componentSource, testSource };
package/files/AGENTS.md CHANGED
@@ -33,6 +33,9 @@ change; both are fast.
33
33
  without its import renders as a plain view.
34
34
  - **There is no DOM.** No `document`, no `window`, no `<div>`, `<span>`, `<button>` or `<input>`,
35
35
  no `@angular/platform-browser`, and no `@angular/animations`. Use the native elements above.
36
+ - **A component's host is a flex item.** `<app-case-list />` commits as a view of its own, with no
37
+ `flex`, so a `flex: 1` scroll view inside it gets a height of zero and shows nothing. A component
38
+ that fills the space it is given sets `host: { style: 'flex: 1' }` (or `:host { flex: 1; }`).
36
39
  - **Text only renders inside `<text>`.** `<view>Hello</view>` compiles and shows nothing.
37
40
  - **Events are native:** `(press)` on `<pressable>`, not `(click)`; `[(value)]` or `(changeText)` on
38
41
  `<text-input>`; `(scroll)`, `(layout)`. A pressable text is `<text pressable (press)="...">`.
@@ -1,5 +1,6 @@
1
- import { Component, signal } from '@angular/core';
1
+ import { Component, inject, signal } from '@angular/core';
2
2
  import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-native/components';
3
+ import { StatusBar } from '@ng-native/device';
3
4
 
4
5
  /**
5
6
  * Element names are lowercase, and that is load-bearing rather than style: an uppercase name is
@@ -28,7 +29,7 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
28
29
  }
29
30
  .screen {
30
31
  flex: 1;
31
- background-color: #101014;
32
+ background-color: #f5f5f7;
32
33
  }
33
34
  .body {
34
35
  flex: 1;
@@ -37,12 +38,12 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
37
38
  padding: 24px;
38
39
  }
39
40
  .title {
40
- color: #ffffff;
41
+ color: #101014;
41
42
  font-size: 28px;
42
43
  font-weight: 700;
43
44
  }
44
45
  .hint {
45
- color: #8b8b96;
46
+ color: #5f5f6b;
46
47
  font-size: 15px;
47
48
  }
48
49
  .button {
@@ -57,8 +58,25 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
57
58
  font-size: 16px;
58
59
  font-weight: 600;
59
60
  }
61
+ @media (prefers-color-scheme: dark) {
62
+ .screen {
63
+ background-color: #101014;
64
+ }
65
+ .title {
66
+ color: #ffffff;
67
+ }
68
+ .hint {
69
+ color: #8b8b96;
70
+ }
71
+ }
60
72
  `,
61
73
  })
62
74
  export class App {
63
75
  protected readonly count = signal(0);
76
+
77
+ constructor() {
78
+ // Dark status bar icons in light mode and light ones in dark mode. Unclaimed, Android keeps
79
+ // light icons whatever the scheme.
80
+ inject(StatusBar).set({ style: 'auto' });
81
+ }
64
82
  }
package/files/src/main.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // Expo's runtime: its fetch, whose response streams a body, and URL, TextDecoderStream and
2
+ // structuredClone. Metro runs it before this file only when something imports it, and nothing
3
+ // else does in a release build, which would then get React Native's fetch, with no body.
4
+ import 'expo';
1
5
  import { AppRegistry, Image, Platform, processColor } from 'react-native';
2
6
  import { mount } from '@ng-native/platform';
3
7
  import { currentConditions, deviceTokens, watchConditions } from '@ng-native/device';
package/generators.json CHANGED
@@ -13,6 +13,28 @@
13
13
  "factory": "./application/index.cjs#application",
14
14
  "schema": "./application/schema.json",
15
15
  "description": "An Angular Native app: an Expo project with start, run-ios, run-android, export, test and typecheck targets."
16
+ },
17
+ "sync-native-modules": {
18
+ "factory": "./sync/index.cjs#syncNativeModules",
19
+ "schema": "./sync/schema.json",
20
+ "description": "Lists the native modules an app's libraries import in the app's package.json, with their config plugins in app.json. A sync generator."
21
+ },
22
+ "library": {
23
+ "aliases": ["lib"],
24
+ "factory": "./library/index.cjs#library",
25
+ "schema": "./library/schema.json",
26
+ "description": "A library of native components, whose tests render on the fake Fabric as an app's do."
27
+ },
28
+ "tailwind": {
29
+ "factory": "./tailwind/index.cjs#tailwind",
30
+ "schema": "./tailwind/schema.json",
31
+ "description": "Sets up Tailwind in an Angular Native app, and the Tailwind-styled workspace libraries it uses."
32
+ },
33
+ "component": {
34
+ "aliases": ["c"],
35
+ "factory": "./component/index.cjs#component",
36
+ "schema": "./component/schema.json",
37
+ "description": "A component for an Angular Native app or library, with native elements and a test that runs in Node."
16
38
  }
17
39
  }
18
40
  }
package/init/index.cjs CHANGED
@@ -17,11 +17,18 @@
17
17
  *
18
18
  * It also adds Babel 7's `@babel/runtime`, the one Expo's Babel preset writes imports of. In an
19
19
  * Angular workspace `@angular-devkit/build-angular` hoists Babel 8's to the root, which has no
20
- * `regenerator`, and every `nx start` warned that Metro fell back to file-based resolution.
20
+ * `regenerator`, and every `nx start` warned that Metro fell back to file-based resolution. And
21
+ * Babel 7's `@babel/core`, which every plugin in that preset peers on, and which the same hoisted
22
+ * Babel 8 otherwise answers.
23
+ *
24
+ * And `@expo/metro`, which `withNxMetro` in the app's `metro.config.js` loads from the app's
25
+ * directory up. It is a dependency of `expo`, which pnpm keeps out of the root's `node_modules`, and
26
+ * loading the config threw "Unable to load Metro config" without it.
21
27
  */
22
28
  const {
23
29
  addDependenciesToPackageJson,
24
30
  formatFiles,
31
+ logger,
25
32
  readJson,
26
33
  readNxJson,
27
34
  updateNxJson,
@@ -88,12 +95,95 @@ function rootDependencies(tree) {
88
95
  return { ...companions, expo, react, 'react-native': reactNative };
89
96
  }
90
97
 
98
+ /**
99
+ * The dependencies of `@nx/expo` with an install script, through `@nx/jest`. Both ship prebuilt
100
+ * binaries for every platform, and the script only builds from source when one is missing, so
101
+ * they are declined rather than allowed. pnpm 11 refuses to install until each one is decided,
102
+ * with `ERR_PNPM_IGNORED_BUILDS`.
103
+ */
104
+ const DECLINED_BUILDS = ['@parcel/watcher', 'unrs-resolver'];
105
+
106
+ const PNPM = 'pnpm-workspace.yaml';
107
+
108
+ /**
109
+ * `allowBuilds`' entries with `name` declined, unless it is already `true` or `false`.
110
+ *
111
+ * @param {string} entries
112
+ * @param {string} name
113
+ */
114
+ function decline(entries, name) {
115
+ const key = name.startsWith('@') ? `'${name}'` : name;
116
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
117
+ const line = new RegExp(`^([ \\t]+)['"]?${escaped}['"]?:[ \\t]*(.*)$`, 'm');
118
+ const found = line.exec(entries);
119
+ if (!found) return `${entries && entries.replace(/\n?$/, '\n')} ${key}: false\n`;
120
+ if (/^(true|false)\b/.test(found[2])) return entries;
121
+ return entries.replace(line, `$1${key}: false`);
122
+ }
123
+
124
+ /**
125
+ * `allowBuilds` as a block mapping, the form pnpm writes: the key alone on its line, with a comment
126
+ * after it at most, then its entries, indented, with blank and comment lines among them. The
127
+ * entries end at their last indented line, before any blank lines that lead to the next key. Null
128
+ * when there is no such block.
129
+ *
130
+ * @param {string} yaml
131
+ * @returns {{ head: string, entries: string, start: number, end: number } | null}
132
+ */
133
+ function allowBuildsBlock(yaml) {
134
+ const key = /^allowBuilds:[ \t]*(?:#.*)?(?:\n|$)/m.exec(yaml);
135
+ if (!key) return null;
136
+ const head = key[0].endsWith('\n') ? key[0] : `${key[0]}\n`;
137
+ const from = key.index + key[0].length;
138
+ let end = from;
139
+ for (let at = from; at < yaml.length;) {
140
+ const next = yaml.indexOf('\n', at);
141
+ const lineEnd = next === -1 ? yaml.length : next + 1;
142
+ const line = yaml.slice(at, lineEnd);
143
+ if (/^[ \t]+\S/.test(line)) end = lineEnd;
144
+ else if (!/^[ \t]*\n?$/.test(line)) break;
145
+ at = lineEnd;
146
+ }
147
+ return { head, entries: yaml.slice(from, end), start: key.index, end };
148
+ }
149
+
150
+ /**
151
+ * Decides the builds above in `pnpm-workspace.yaml`'s `allowBuilds`, which pnpm 10.26 and later
152
+ * read. A decision the workspace already made stays, and the placeholder pnpm writes after refusing
153
+ * an install is settled. A workspace without pnpm is left alone.
154
+ *
155
+ * Edited as text, so the file keeps its comments and layout. An `allowBuilds` in any form other than
156
+ * a block mapping, such as a flow mapping, is left as it is with a warning: a second key beside it
157
+ * would make the file invalid YAML, which pnpm refuses to read.
158
+ */
159
+ function declineBuilds(tree) {
160
+ if (!tree.exists(PNPM) && !tree.exists('pnpm-lock.yaml')) return;
161
+ const yaml = tree.read(PNPM, 'utf-8') ?? '';
162
+ const block = allowBuildsBlock(yaml);
163
+ if (!block && /^allowBuilds:/m.test(yaml)) {
164
+ logger.warn(
165
+ `${PNPM} sets allowBuilds in a form this cannot add to. Add ` +
166
+ `${DECLINED_BUILDS.map((name) => `${name}: false`).join(' and ')} to it, ` +
167
+ 'or pnpm 11 stops the install until they are decided.',
168
+ );
169
+ return;
170
+ }
171
+ const entries = DECLINED_BUILDS.reduce(decline, block?.entries ?? '');
172
+ const allowBuilds = `${block?.head ?? 'allowBuilds:\n'}${entries}`;
173
+ if (block) {
174
+ tree.write(PNPM, yaml.slice(0, block.start) + allowBuilds + yaml.slice(block.end));
175
+ } else {
176
+ tree.write(PNPM, yaml ? `${yaml.replace(/\n*$/, '\n')}\n${allowBuilds}` : allowBuilds);
177
+ }
178
+ }
179
+
91
180
  /**
92
181
  * @param {import('@nx/devkit').Tree} tree
93
182
  * @param {{ skipInstall?: boolean, skipFormat?: boolean }} options
94
183
  */
95
184
  async function init(tree, options = {}) {
96
185
  registerExpoPlugin(tree);
186
+ declineBuilds(tree);
97
187
  const { dependencies, devDependencies } = readJson(tree, 'package.json');
98
188
  const existing = Object.keys({ ...dependencies, ...devDependencies });
99
189
  const companions = await asSaved(tree, rootDependencies(tree), existing);