@expo-harmony/patch-project 55.1.27-harmony.1

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 ADDED
@@ -0,0 +1,89 @@
1
+ # @expo-harmony/patch-project
2
+
3
+ [**GitHub 仓库**](https://github.com/renbaoshuo/expo-harmony/tree/master/packages/patch-project)
4
+
5
+ 这个包把 `harmony/` 里的手动修改保存为 Git 补丁,在 prebuild 时自动应用回去。它提供生成补丁的命令和负责应用补丁的 config plugin,流程对应 Expo 官方的 [patch-project](https://docs.expo.dev/config-plugins/patch-project/)。
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ npm install @expo-harmony/patch-project
11
+ ```
12
+
13
+ 在 app config 的 `plugins` 里注册插件:
14
+
15
+ ```json
16
+ {
17
+ "expo": {
18
+ "plugins": ["@expo-harmony/patch-project"]
19
+ }
20
+ }
21
+ ```
22
+
23
+ ## 生成补丁
24
+
25
+ 先跑一次 prebuild 得到 `harmony/`,手动改完后执行:
26
+
27
+ ```sh
28
+ npx @expo-harmony/patch-project
29
+ # 指定项目目录
30
+ npx @expo-harmony/patch-project ./my-app
31
+ ```
32
+
33
+ 命令把当前原生工程和 prebuild 生成的默认工程做比较,将差异存储成一个 patch。patch 会存放在 `cng-patches/harmony+<templateChecksum>.patch`,把 `cng-patches/` 提交到仓库即可。
34
+
35
+ ## 应用补丁
36
+
37
+ 跑 `expo-harmony prebuild` 或 `expo-harmony prebuild --clean`,插件查找并应用与当前模板匹配的补丁。没有补丁就照常生成;只有其他模板的补丁时发出警告并跳过,提示重新审查和生成。有多份补丁时只应用校验和匹配的那一份。
38
+
39
+ 补丁在常规 Mods、自定义文件 Mods 和 autolinking 之后应用。补丁新增的文件纳入 CNG 文件管理,改补丁或移除插件时会清掉不再声明的新增文件。既有文件上的修改不会自动撤销,要完整重建就用 `prebuild --clean`。
40
+
41
+ ## 选项
42
+
43
+ 命令参数:
44
+
45
+ | 参数 | 作用 |
46
+ | ----------------------- | -------------------------------------------------------- |
47
+ | `--clean` | 补丁保存成功后删除原生目录,下次 prebuild 重新生成再应用 |
48
+ | `-p, --platform <name>` | `harmony` 或 `all`,默认 `harmony`,两者都只处理 Harmony |
49
+ | `-h, --help` | 显示帮助 |
50
+
51
+ 插件选项:
52
+
53
+ | 选项 | 默认值 | 说明 |
54
+ | ------------------- | ------------- | -------------------------------------- |
55
+ | `patchRoot` | `cng-patches` | 查找补丁的目录,相对项目根目录 |
56
+ | `changedLinesLimit` | `300` | 补丁改动行数超过该值时警告,不阻止应用 |
57
+
58
+ ```json
59
+ [
60
+ "@expo-harmony/patch-project",
61
+ {
62
+ "patchRoot": "cng-patches",
63
+ "changedLinesLimit": 300
64
+ }
65
+ ]
66
+ ```
67
+
68
+ `patchRoot` 必须是非空路径,`changedLinesLimit` 必须是非负数。
69
+
70
+ 注意生成 patch 的命令只往 `cng-patches/` 写补丁,插件配置使用别的目录查找 patch 时,需要自己把 patch 文件移过去。
71
+
72
+ ## 支持的修改
73
+
74
+ 支持文本和二进制文件、新增和删除,以及可执行权限等修改,还有指向 Harmony 工程内部的相对符号链接。
75
+
76
+ 文件是否进入补丁遵循 `harmony/.gitignore` 及其子目录中的 Git 忽略规则。默认的 prebuild 模板已忽略常见的依赖目录和构建产物,需要保留其中的文件时,可以用 `!` 规则显式包含。
77
+
78
+ 补丁路径和符号链接不能越出 Harmony 工程,也不能修改 `.git` 元数据。
79
+
80
+ ## 升级
81
+
82
+ SDK、模板或其他插件升级后补丁可能对不上,要重新审查并生成。改动量大、长期维护的配置更适合写成静态 Mod。
83
+
84
+ ## Author
85
+
86
+ **expo-harmony** © [Baoshuo](https://github.com/renbaoshuo), Released under the MIT License.<br>
87
+ Authored and maintained by Baoshuo with help from [contributors](https://github.com/renbaoshuo/expo-harmony/contributors).
88
+
89
+ > [Personal Website](https://baoshuo.ren) · [Blog](https://blog.baoshuo.ren) · GitHub [@renbaoshuo](https://github.com/renbaoshuo) · Twitter [@renbaoshuo](https://twitter.com/renbaoshuo)
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./build/withPatchPlugin').default;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_util_1 = require("node:util");
10
+ const errors_1 = require("../errors");
11
+ const patchProjectAsync_1 = require("./patchProjectAsync");
12
+ async function main() {
13
+ const { values, positionals } = (0, node_util_1.parseArgs)({
14
+ allowPositionals: true,
15
+ options: {
16
+ help: { type: 'boolean', short: 'h' },
17
+ clean: { type: 'boolean' },
18
+ platform: { type: 'string', short: 'p', default: 'harmony' },
19
+ },
20
+ });
21
+ if (values.help) {
22
+ console.log(`Usage: npx @expo-harmony/patch-project [project] [options]
23
+
24
+ Preserve manual Harmony native changes as CNG patches.
25
+
26
+ Options:
27
+ --clean Delete harmony/ after successfully saving the patch
28
+ -p, --platform <name> harmony or all (default: harmony)
29
+ -h, --help Show this help`);
30
+ return;
31
+ }
32
+ if (positionals.length > 1 || !['harmony', 'all'].includes(values.platform)) {
33
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_CONFIG_INVALID', 'Expected one project directory and --platform harmony or all.');
34
+ }
35
+ const root = await promises_1.default.realpath(node_path_1.default.resolve(positionals[0] || '.'));
36
+ await promises_1.default.access(node_path_1.default.join(root, 'package.json'));
37
+ process.env.EXPO_HARMONY = '1';
38
+ process.env.EXPO_METRO_TARGET = 'harmony';
39
+ const file = await (0, patchProjectAsync_1.patchProjectAsync)(root, { clean: values.clean });
40
+ console.log(file ? `Saved Harmony patch: ${file}` : 'No manual changes detected; removed previous Harmony patches.');
41
+ if (file)
42
+ console.log('Add "@expo-harmony/patch-project" to your app config plugins to apply patches during prebuild.');
43
+ }
44
+ main().catch((cause) => {
45
+ console.error(`[${cause.code || 'ERR_HARMONY_PATCH_FAILED'}] ${cause.message}`);
46
+ process.exitCode = 1;
47
+ });
@@ -0,0 +1,3 @@
1
+ export declare function patchProjectAsync(root: string, options?: {
2
+ clean?: boolean;
3
+ }): Promise<string>;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.patchProjectAsync = patchProjectAsync;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const prebuild_1 = require("@expo-harmony/cli/internal/prebuild");
10
+ const config_plugins_1 = require("@expo-harmony/config-plugins");
11
+ const errors_1 = require("../errors");
12
+ const gitPatch_1 = require("../gitPatch");
13
+ const patches_1 = require("../patches");
14
+ async function patchProjectAsync(root, options = {}) {
15
+ return (0, prebuild_1.withHarmonyProjectLockAsync)(root, 'patch-project', async () => {
16
+ const native = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(root, 'harmony');
17
+ let stat;
18
+ try {
19
+ stat = await promises_1.default.lstat(native);
20
+ }
21
+ catch (cause) {
22
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Cannot read the harmony directory. Run prebuild first.', {
23
+ cause, file: native,
24
+ });
25
+ }
26
+ if (!stat.isDirectory() || !(await promises_1.default.readdir(native)).length) {
27
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'The harmony directory must exist and contain a native project. Run prebuild first.');
28
+ }
29
+ if (options.clean)
30
+ await (0, prebuild_1.assertSafeCleanTarget)(root);
31
+ const directory = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(root, 'cng-patches');
32
+ const previous = await (0, patches_1.getPatchFilesAsync)(directory);
33
+ const result = await (0, prebuild_1.withGeneratedProjectAsync)(root, { clean: true, skipPatches: true }, async (expected, checksum) => ({
34
+ patch: await (0, gitPatch_1.generatePatchAsync)(node_path_1.default.join(expected, 'harmony'), native),
35
+ file: node_path_1.default.join(directory, `harmony+${checksum}.patch`),
36
+ }));
37
+ if (result.patch.trim())
38
+ await (0, config_plugins_1.atomicWrite)(result.file, result.patch);
39
+ for (const name of previous) {
40
+ const file = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(directory, name);
41
+ if (!result.patch.trim() || file !== result.file)
42
+ await promises_1.default.rm(file);
43
+ }
44
+ if (options.clean)
45
+ await promises_1.default.rm(native, { recursive: true });
46
+ return result.patch.trim() ? result.file : null;
47
+ });
48
+ }
@@ -0,0 +1,4 @@
1
+ import { HarmonyConfigPluginError, type HarmonyConfigPluginErrorOptions } from '@expo-harmony/config-plugins';
2
+ export declare class HarmonyPatchError extends HarmonyConfigPluginError {
3
+ constructor(code: string, message: string, options?: HarmonyConfigPluginErrorOptions);
4
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HarmonyPatchError = void 0;
4
+ const config_plugins_1 = require("@expo-harmony/config-plugins");
5
+ class HarmonyPatchError extends config_plugins_1.HarmonyConfigPluginError {
6
+ constructor(code, message, options = {}) {
7
+ super(code, message, { operation: 'patch-project', ...options });
8
+ this.name = 'HarmonyPatchError';
9
+ }
10
+ }
11
+ exports.HarmonyPatchError = HarmonyPatchError;
@@ -0,0 +1,7 @@
1
+ export declare function generatePatchAsync(baseline: string, current: string): Promise<string>;
2
+ export interface PatchResult {
3
+ files: string[];
4
+ created: string[];
5
+ }
6
+ export declare function getPatchChangedLinesAsync(file: string): Promise<number>;
7
+ export declare function applyPatchAsync(root: string, content: string): Promise<PatchResult>;
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generatePatchAsync = generatePatchAsync;
7
+ exports.getPatchChangedLinesAsync = getPatchChangedLinesAsync;
8
+ exports.applyPatchAsync = applyPatchAsync;
9
+ const node_child_process_1 = require("node:child_process");
10
+ const promises_1 = __importDefault(require("node:fs/promises"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const node_util_1 = require("node:util");
14
+ const config_plugins_1 = require("@expo-harmony/config-plugins");
15
+ const expo_modules_autolinking_1 = require("@expo-harmony/expo-modules-autolinking");
16
+ const errors_1 = require("./errors");
17
+ const exec = (0, node_util_1.promisify)(node_child_process_1.execFile);
18
+ function isProjectPath(relative) {
19
+ // eslint-disable-next-line no-control-regex -- Control characters are invalid native patch paths.
20
+ return !!relative && !relative.includes('\\') && !/[\x00-\x1f\x7f]/u.test(relative)
21
+ && !relative.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')
22
+ && !/^[A-Za-z]:/u.test(relative);
23
+ }
24
+ async function git(cwd, args) {
25
+ // Ambient Git overrides must not redirect commands outside the staging repository.
26
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
27
+ try {
28
+ return (await exec('git', [
29
+ '-c', 'core.autocrlf=false', '-c', 'core.quotePath=false', '-c', 'core.fileMode=true', ...args,
30
+ ], {
31
+ cwd,
32
+ env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' },
33
+ maxBuffer: 64 * 1024 * 1024,
34
+ })).stdout;
35
+ }
36
+ catch (cause) {
37
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Git patch operation failed: ${cause.stderr || cause.message}`, { cause, operation: 'patch-project' });
38
+ }
39
+ }
40
+ async function listFiles(source) {
41
+ const staging = await promises_1.default.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'harmony-patch-files-'));
42
+ try {
43
+ await git(staging, ['init', '--quiet', '--bare']);
44
+ const output = await git(staging, [`--work-tree=${node_path_1.default.resolve(source)}`, 'ls-files', '--others', '--exclude-standard', '-z']);
45
+ return output.split('\0').filter(Boolean);
46
+ }
47
+ finally {
48
+ await promises_1.default.rm(staging, { recursive: true, force: true });
49
+ }
50
+ }
51
+ async function snapshot(source, target, files) {
52
+ await promises_1.default.mkdir(target, { recursive: true });
53
+ for (const name of files) {
54
+ if (!isProjectPath(name))
55
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Invalid native patch path: ${name}.`);
56
+ await config_plugins_1.HarmonyPaths.resolveHarmonyPath(source, name);
57
+ const from = node_path_1.default.join(source, name);
58
+ const to = node_path_1.default.join(target, name);
59
+ let stat;
60
+ try {
61
+ stat = await promises_1.default.lstat(from);
62
+ }
63
+ catch (cause) {
64
+ if (cause.code === 'ENOENT')
65
+ continue;
66
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Cannot inspect ${from}.`, { cause, file: from });
67
+ }
68
+ if (!stat.isFile() && !stat.isSymbolicLink())
69
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch source must be a file: ${from}.`);
70
+ await promises_1.default.mkdir(node_path_1.default.dirname(to), { recursive: true });
71
+ await promises_1.default.cp(from, to, { verbatimSymlinks: true });
72
+ }
73
+ }
74
+ async function changedFiles(root, patch) {
75
+ const stats = await git(root, ['apply', '--numstat', '-z', patch]);
76
+ const files = stats.split('\0').filter(Boolean).map((row) => {
77
+ const match = /^(?:\d+|-)\t(?:\d+|-)\t(harmony\/(.+))$/u.exec(row);
78
+ if (!match || !isProjectPath(match[2])) {
79
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Patch paths must remain inside the Harmony project and cannot modify Git metadata.');
80
+ }
81
+ return match[2];
82
+ });
83
+ return [...new Set(files)];
84
+ }
85
+ async function generatePatchAsync(baseline, current) {
86
+ const staging = await promises_1.default.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'harmony-patch-diff-'));
87
+ try {
88
+ await git(staging, ['init', '--quiet']);
89
+ const native = node_path_1.default.join(staging, 'harmony');
90
+ const files = await listFiles(baseline);
91
+ await snapshot(baseline, native, files);
92
+ // Temporary dependency paths must not appear in patches.
93
+ const root = await promises_1.default.realpath(node_path_1.default.dirname(baseline));
94
+ const file = node_path_1.default.join(root, '.expo/harmony/autolinking.json');
95
+ let manifest;
96
+ try {
97
+ manifest = await promises_1.default.readFile(file, 'utf8');
98
+ }
99
+ catch (cause) {
100
+ if (cause.code !== 'ENOENT') {
101
+ throw new errors_1.HarmonyPatchError(cause.code || 'ERR_HARMONY_PATCH_FAILED', cause.message || `Cannot read autolinking manifest ${file}.`, { cause, file, operation: 'patch-project' });
102
+ }
103
+ }
104
+ if (manifest) {
105
+ const file = node_path_1.default.join(native, config_plugins_1.HarmonyPaths.HARMONY_PATHS.rootOhPackage);
106
+ const canonical = (0, expo_modules_autolinking_1.canonicalizeAutolinkingArtifacts)({
107
+ manifestSource: manifest,
108
+ ohPackageSource: await promises_1.default.readFile(file, 'utf8'),
109
+ generatedProjectRoot: root,
110
+ canonicalProjectRoot: await promises_1.default.realpath(node_path_1.default.dirname(current)),
111
+ harmonyProjectPath: await promises_1.default.realpath(current),
112
+ });
113
+ await promises_1.default.writeFile(file, canonical.ohPackageSource);
114
+ }
115
+ await git(staging, ['add', '-f', '--all', '--', 'harmony']);
116
+ await promises_1.default.rm(native, { recursive: true });
117
+ await snapshot(current, native, [...new Set([...files, ...await listFiles(current)])]);
118
+ // Intent-to-add includes new text and binary files without changing baseline entries.
119
+ const untracked = (await git(staging, ['ls-files', '--others', '-z', '--', 'harmony'])).split('\0').filter(Boolean);
120
+ if (untracked.length)
121
+ await git(staging, ['add', '-f', '-N', '--', ...untracked]);
122
+ return await git(staging, [
123
+ 'diff', '--binary', '--ignore-space-at-eol', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', '--', 'harmony',
124
+ ]);
125
+ }
126
+ finally {
127
+ await promises_1.default.rm(staging, { recursive: true, force: true });
128
+ }
129
+ }
130
+ async function getPatchChangedLinesAsync(file) {
131
+ const stats = await git(node_path_1.default.dirname(file), ['apply', '--numstat', file]);
132
+ return stats.split('\n').reduce((total, row) => {
133
+ const [added, deleted] = row.split('\t', 2).map(Number);
134
+ return total + (Number.isFinite(added) ? added : 0) + (Number.isFinite(deleted) ? deleted : 0);
135
+ }, 0);
136
+ }
137
+ async function applyPatchAsync(root, content) {
138
+ if (!content.trim())
139
+ return { files: [], created: [] };
140
+ if (/^(?:old mode|new mode|new file mode|deleted file mode) (?!100644$|100755$|120000$)/mu.test(content)
141
+ || /^(?:rename|copy) (?:from|to) /mu.test(content)) {
142
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Use file or symlink patches; regenerate renames as delete/add changes.');
143
+ }
144
+ const staging = await promises_1.default.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'harmony-patch-apply-'));
145
+ try {
146
+ await git(staging, ['init', '--quiet']);
147
+ const patch = node_path_1.default.join(staging, 'changes.patch');
148
+ await promises_1.default.writeFile(patch, content);
149
+ const files = await changedFiles(staging, patch);
150
+ const native = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(root, 'harmony');
151
+ await snapshot(native, node_path_1.default.join(staging, 'harmony'), files);
152
+ const chunks = content.split(/(?=^diff --git )/mu).filter(chunk => chunk.startsWith('diff --git '));
153
+ if (!chunks.length) {
154
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Expected a Git patch generated by npx @expo-harmony/patch-project.');
155
+ }
156
+ const sections = new Map();
157
+ for (const chunk of chunks) {
158
+ await promises_1.default.writeFile(patch, chunk);
159
+ const names = await changedFiles(staging, patch);
160
+ if (names.length !== 1)
161
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Each patch section must modify one file.');
162
+ sections.set(names[0], (sections.get(names[0]) ?? '') + chunk);
163
+ }
164
+ const created = [];
165
+ for (const [name, source] of sections) {
166
+ if (/^new file mode /mu.test(source) && !/^deleted file mode /mu.test(source))
167
+ created.push(name);
168
+ await promises_1.default.writeFile(patch, source);
169
+ try {
170
+ await git(staging, ['apply', '--ignore-whitespace', '--check', patch]);
171
+ }
172
+ catch (cause) {
173
+ // Other mods may regenerate only some files. Accept already-applied
174
+ // sections independently, while validating the complete patch in staging.
175
+ try {
176
+ await git(staging, ['apply', '--ignore-whitespace', '--reverse', '--check', patch]);
177
+ }
178
+ catch {
179
+ throw new errors_1.HarmonyPatchError(cause.code, cause.message, {
180
+ cause, file: patch, operation: cause.operation,
181
+ });
182
+ }
183
+ continue;
184
+ }
185
+ await git(staging, ['apply', '--ignore-whitespace', '--binary', patch]);
186
+ }
187
+ if (sections.size !== files.length || files.some(file => !sections.has(file))) {
188
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Patch section paths do not match its file summary.');
189
+ }
190
+ const writes = [];
191
+ for (const file of files) {
192
+ const target = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(native, file);
193
+ const staged = node_path_1.default.join(staging, 'harmony', file);
194
+ let stat;
195
+ try {
196
+ stat = await promises_1.default.lstat(staged);
197
+ }
198
+ catch (cause) {
199
+ if (cause.code !== 'ENOENT') {
200
+ throw new errors_1.HarmonyPatchError(cause.code || 'ERR_HARMONY_PATCH_FAILED', cause.message || `Cannot inspect staged patch output ${file}.`, { cause, file: staged, operation: 'patch-project' });
201
+ }
202
+ }
203
+ if (stat?.isSymbolicLink()) {
204
+ const link = await promises_1.default.readlink(staged);
205
+ if (node_path_1.default.isAbsolute(link) || !config_plugins_1.HarmonyPaths.isInside(native, node_path_1.default.resolve(node_path_1.default.dirname(target), link))) {
206
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch symlink must remain inside the Harmony project: ${file}.`);
207
+ }
208
+ await config_plugins_1.HarmonyPaths.resolveHarmonyPath(native, node_path_1.default.relative(native, node_path_1.default.resolve(node_path_1.default.dirname(target), link)));
209
+ writes.push({ target, link });
210
+ }
211
+ else if (stat?.isFile()) {
212
+ writes.push({ target, content: Uint8Array.from(await promises_1.default.readFile(staged)), mode: stat.mode & 0o777 });
213
+ }
214
+ else if (!stat)
215
+ writes.push({ target });
216
+ else
217
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch output must be a file: ${file}.`);
218
+ }
219
+ for (const { target, content, link, mode } of writes) {
220
+ if (content) {
221
+ await (0, config_plugins_1.atomicWrite)(target, content);
222
+ await promises_1.default.chmod(target, mode);
223
+ }
224
+ else {
225
+ await promises_1.default.rm(target, { force: true });
226
+ if (link !== undefined) {
227
+ await promises_1.default.mkdir(node_path_1.default.dirname(target), { recursive: true });
228
+ await promises_1.default.symlink(link, target);
229
+ }
230
+ }
231
+ }
232
+ return { files, created };
233
+ }
234
+ finally {
235
+ await promises_1.default.rm(staging, { recursive: true, force: true });
236
+ }
237
+ }
@@ -0,0 +1 @@
1
+ export declare function getPatchFilesAsync(root: string): Promise<string[]>;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getPatchFilesAsync = getPatchFilesAsync;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const errors_1 = require("./errors");
9
+ async function getPatchFilesAsync(root) {
10
+ try {
11
+ return (await promises_1.default.readdir(root)).filter(name => name.startsWith('harmony') && name.endsWith('.patch')).sort();
12
+ }
13
+ catch (cause) {
14
+ if (cause.code === 'ENOENT')
15
+ return [];
16
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Cannot read patch directory ${root}.`, { cause, file: root });
17
+ }
18
+ }
@@ -0,0 +1,7 @@
1
+ import { type HarmonyConfigPlugin } from '@expo-harmony/config-plugins';
2
+ export interface PatchPluginProps {
3
+ patchRoot?: string;
4
+ changedLinesLimit?: number;
5
+ }
6
+ declare const _default: HarmonyConfigPlugin<void | PatchPluginProps>;
7
+ export default _default;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const promises_1 = __importDefault(require("node:fs/promises"));
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const config_plugins_1 = require("@expo-harmony/config-plugins");
9
+ const internal_1 = require("@expo-harmony/config-plugins/internal");
10
+ const config_plugins_2 = require("@expo/config-plugins");
11
+ const errors_1 = require("./errors");
12
+ const gitPatch_1 = require("./gitPatch");
13
+ const patches_1 = require("./patches");
14
+ const owner = '@expo-harmony/patch-project';
15
+ const withPatchPlugin = (config, props) => {
16
+ const options = props || {};
17
+ const limit = options.changedLinesLimit ?? 300;
18
+ if ((options.patchRoot !== undefined && (typeof options.patchRoot !== 'string' || !options.patchRoot.trim()))
19
+ || !Number.isFinite(limit) || limit < 0) {
20
+ throw new errors_1.HarmonyPatchError('ERR_HARMONY_CONFIG_INVALID', 'patchRoot must be a non-empty directory path and changedLinesLimit must be a non-negative number.');
21
+ }
22
+ (0, config_plugins_1.registerHarmonyConfigPlugin)(config, owner);
23
+ return (0, internal_1.withProjectPatch)(config, async (mod) => {
24
+ if (mod.modRequest.introspect || process.env.EXPO_HARMONY_SKIP_PATCHES === '1')
25
+ return mod;
26
+ const root = mod.modRequest.projectRoot;
27
+ const directory = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(root, options.patchRoot ?? 'cng-patches');
28
+ const files = await (0, patches_1.getPatchFilesAsync)(directory);
29
+ if (!files.length)
30
+ return mod;
31
+ const checksum = mod._internal?.templateChecksum;
32
+ const name = `harmony+${checksum}.patch`;
33
+ if (!checksum || !files.includes(name)) {
34
+ config_plugins_2.WarningAggregator.addWarningForPlatform('harmony', owner, `No patch matches the current template${checksum ? ` (${checksum})` : ''}. Existing Harmony patches were not applied. Review and regenerate them with npx @expo-harmony/patch-project.`);
35
+ return mod;
36
+ }
37
+ if (files.length > 1) {
38
+ config_plugins_2.WarningAggregator.addWarningForPlatform('harmony', owner, `Multiple Harmony patches found; only ${name} will be applied.`);
39
+ }
40
+ const file = await config_plugins_1.HarmonyPaths.resolveHarmonyPath(directory, name);
41
+ const source = await promises_1.default.readFile(file, 'utf8');
42
+ if (!source.trim())
43
+ return mod;
44
+ const changed = await (0, gitPatch_1.getPatchChangedLinesAsync)(file);
45
+ if (changed > limit) {
46
+ config_plugins_2.WarningAggregator.addWarningForPlatform('harmony', owner, `${name} has ${changed} changed lines, exceeding the warning limit of ${limit}. Consider a config plugin for larger changes.`);
47
+ }
48
+ const result = await (0, gitPatch_1.applyPatchAsync)(root, source);
49
+ (0, config_plugins_1.registerHarmonyConfigPlugin)(mod, owner, { files: result.created });
50
+ for (const relative of result.files) {
51
+ const file = node_path_1.default.join(mod.modRequest.platformProjectRoot, relative);
52
+ const managed = mod._internal?.harmonyManagedFiles?.find(item => item.path === `harmony/${relative}`);
53
+ (0, config_plugins_1.recordManagedFile)(mod, file, managed?.owner ?? (result.created.includes(relative) ? owner : 'patch'));
54
+ }
55
+ return mod;
56
+ });
57
+ };
58
+ exports.default = (0, config_plugins_1.createRunOncePlugin)(withPatchPlugin, owner);
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@expo-harmony/patch-project",
3
+ "version": "55.1.27-harmony.1",
4
+ "keywords": [
5
+ "react-native",
6
+ "expo",
7
+ "expo-harmony",
8
+ "harmonyos"
9
+ ],
10
+ "homepage": "https://github.com/renbaoshuo/expo-harmony/tree/master/packages/patch-project#readme",
11
+ "bugs": "https://github.com/renbaoshuo/expo-harmony/issues",
12
+ "license": "MIT",
13
+ "author": "Baoshuo <i@baoshuo.ren>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/renbaoshuo/expo-harmony.git",
17
+ "directory": "packages/patch-project"
18
+ },
19
+ "main": "build/withPatchPlugin.js",
20
+ "types": "build/withPatchPlugin.d.ts",
21
+ "scripts": {
22
+ "prebuild": "yarn workspace @expo-harmony/cli build",
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepare": "yarn build",
25
+ "typecheck": "tsc --noEmit -p tsconfig.json"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./build/withPatchPlugin.d.ts",
30
+ "require": "./build/withPatchPlugin.js",
31
+ "default": "./build/withPatchPlugin.js"
32
+ },
33
+ "./app.plugin.js": "./app.plugin.js",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "dependencies": {
37
+ "@expo-harmony/cli": "55.0.26-harmony.9",
38
+ "@expo-harmony/config-plugins": "55.0.10-harmony.3",
39
+ "@expo-harmony/expo-modules-autolinking": "55.0.25-harmony.3",
40
+ "@expo/config": "55.0.17",
41
+ "@expo/config-plugins": "55.0.10"
42
+ },
43
+ "peerDependencies": {
44
+ "expo": "55.0.26"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "18.19.0",
48
+ "typescript": "6.0.3"
49
+ },
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "description": "Preserve manual Harmony native changes with CNG patches",
57
+ "bin": {
58
+ "expo-harmony-patch-project": "build/cli/index.js"
59
+ },
60
+ "files": [
61
+ "app.plugin.js",
62
+ "build"
63
+ ]
64
+ }