@expo-harmony/patch-project 55.1.27-harmony.1 → 55.1.27-harmony.2
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/package.json +5 -10
- package/src/cli/index.ts +49 -0
- package/src/cli/patchProjectAsync.ts +47 -0
- package/src/errors.ts +9 -0
- package/src/gitPatch.ts +264 -0
- package/src/patches.ts +13 -0
- package/src/withPatchPlugin.ts +69 -0
- package/tsconfig.json +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo-harmony/patch-project",
|
|
3
|
-
"version": "55.1.27-harmony.
|
|
3
|
+
"version": "55.1.27-harmony.2",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"react-native",
|
|
6
6
|
"expo",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"prepare": "yarn build",
|
|
25
25
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
26
26
|
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"expo-harmony-patch-project": "build/cli/index.js"
|
|
29
|
+
},
|
|
27
30
|
"exports": {
|
|
28
31
|
".": {
|
|
29
32
|
"types": "./build/withPatchPlugin.d.ts",
|
|
@@ -52,13 +55,5 @@
|
|
|
52
55
|
},
|
|
53
56
|
"publishConfig": {
|
|
54
57
|
"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
|
-
]
|
|
58
|
+
}
|
|
64
59
|
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { HarmonyPatchError } from '../errors';
|
|
8
|
+
import { patchProjectAsync } from './patchProjectAsync';
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const { values, positionals } = parseArgs({
|
|
12
|
+
allowPositionals: true,
|
|
13
|
+
options: {
|
|
14
|
+
help: { type: 'boolean', short: 'h' },
|
|
15
|
+
clean: { type: 'boolean' },
|
|
16
|
+
platform: { type: 'string', short: 'p', default: 'harmony' },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (values.help) {
|
|
20
|
+
console.log(`Usage: npx @expo-harmony/patch-project [project] [options]
|
|
21
|
+
|
|
22
|
+
Preserve manual Harmony native changes as CNG patches.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--clean Delete harmony/ after successfully saving the patch
|
|
26
|
+
-p, --platform <name> harmony or all (default: harmony)
|
|
27
|
+
-h, --help Show this help`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (positionals.length > 1 || !['harmony', 'all'].includes(values.platform)) {
|
|
31
|
+
throw new HarmonyPatchError('ERR_HARMONY_CONFIG_INVALID', 'Expected one project directory and --platform harmony or all.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const root = await fs.realpath(path.resolve(positionals[0] || '.'));
|
|
35
|
+
await fs.access(path.join(root, 'package.json'));
|
|
36
|
+
|
|
37
|
+
process.env.EXPO_HARMONY = '1';
|
|
38
|
+
process.env.EXPO_METRO_TARGET = 'harmony';
|
|
39
|
+
|
|
40
|
+
const file = await patchProjectAsync(root, { clean: values.clean });
|
|
41
|
+
|
|
42
|
+
console.log(file ? `Saved Harmony patch: ${file}` : 'No manual changes detected; removed previous Harmony patches.');
|
|
43
|
+
if (file) console.log('Add "@expo-harmony/patch-project" to your app config plugins to apply patches during prebuild.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
main().catch((cause) => {
|
|
47
|
+
console.error(`[${cause.code || 'ERR_HARMONY_PATCH_FAILED'}] ${cause.message}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { assertSafeCleanTarget, withGeneratedProjectAsync, withHarmonyProjectLockAsync } from '@expo-harmony/cli/internal/prebuild';
|
|
5
|
+
import { atomicWrite, HarmonyPaths } from '@expo-harmony/config-plugins';
|
|
6
|
+
|
|
7
|
+
import { HarmonyPatchError } from '../errors';
|
|
8
|
+
import { generatePatchAsync } from '../gitPatch';
|
|
9
|
+
import { getPatchFilesAsync } from '../patches';
|
|
10
|
+
|
|
11
|
+
export async function patchProjectAsync(root: string, options: { clean?: boolean } = {}) {
|
|
12
|
+
return withHarmonyProjectLockAsync(root, 'patch-project', async () => {
|
|
13
|
+
const native = await HarmonyPaths.resolveHarmonyPath(root, 'harmony');
|
|
14
|
+
let stat;
|
|
15
|
+
try {
|
|
16
|
+
stat = await fs.lstat(native);
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Cannot read the harmony directory. Run prebuild first.', {
|
|
19
|
+
cause, file: native,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!stat.isDirectory() || !(await fs.readdir(native)).length) {
|
|
24
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'The harmony directory must exist and contain a native project. Run prebuild first.');
|
|
25
|
+
}
|
|
26
|
+
if (options.clean) await assertSafeCleanTarget(root);
|
|
27
|
+
|
|
28
|
+
const directory = await HarmonyPaths.resolveHarmonyPath(root, 'cng-patches');
|
|
29
|
+
const previous = await getPatchFilesAsync(directory);
|
|
30
|
+
const result = await withGeneratedProjectAsync(
|
|
31
|
+
root, { clean: true, skipPatches: true }, async (expected, checksum) => ({
|
|
32
|
+
patch: await generatePatchAsync(path.join(expected, 'harmony'), native),
|
|
33
|
+
file: path.join(directory, `harmony+${checksum}.patch`),
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (result.patch.trim()) await atomicWrite(result.file, result.patch);
|
|
38
|
+
for (const name of previous) {
|
|
39
|
+
const file = await HarmonyPaths.resolveHarmonyPath(directory, name);
|
|
40
|
+
if (!result.patch.trim() || file !== result.file) await fs.rm(file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.clean) await fs.rm(native, { recursive: true });
|
|
44
|
+
|
|
45
|
+
return result.patch.trim() ? result.file : null;
|
|
46
|
+
});
|
|
47
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { HarmonyConfigPluginError, type HarmonyConfigPluginErrorOptions } from '@expo-harmony/config-plugins';
|
|
2
|
+
|
|
3
|
+
export class HarmonyPatchError extends HarmonyConfigPluginError {
|
|
4
|
+
constructor(code: string, message: string, options: HarmonyConfigPluginErrorOptions = {}) {
|
|
5
|
+
super(code, message, { operation: 'patch-project', ...options });
|
|
6
|
+
|
|
7
|
+
this.name = 'HarmonyPatchError';
|
|
8
|
+
}
|
|
9
|
+
}
|
package/src/gitPatch.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { atomicWrite, HarmonyPaths } from '@expo-harmony/config-plugins';
|
|
8
|
+
import { canonicalizeAutolinkingArtifacts } from '@expo-harmony/expo-modules-autolinking';
|
|
9
|
+
|
|
10
|
+
import { HarmonyPatchError } from './errors';
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
function isProjectPath(relative: string): boolean {
|
|
15
|
+
// eslint-disable-next-line no-control-regex -- Control characters are invalid native patch paths.
|
|
16
|
+
return !!relative && !relative.includes('\\') && !/[\x00-\x1f\x7f]/u.test(relative)
|
|
17
|
+
&& !relative.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')
|
|
18
|
+
&& !/^[A-Za-z]:/u.test(relative);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
22
|
+
// Ambient Git overrides must not redirect commands outside the staging repository.
|
|
23
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return (await exec('git', [
|
|
27
|
+
'-c', 'core.autocrlf=false', '-c', 'core.quotePath=false', '-c', 'core.fileMode=true', ...args,
|
|
28
|
+
], {
|
|
29
|
+
cwd,
|
|
30
|
+
env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' },
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
})).stdout;
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Git patch operation failed: ${cause.stderr || cause.message}`, { cause, operation: 'patch-project' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listFiles(source: string): Promise<string[]> {
|
|
39
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'harmony-patch-files-'));
|
|
40
|
+
try {
|
|
41
|
+
await git(staging, ['init', '--quiet', '--bare']);
|
|
42
|
+
const output = await git(staging, [`--work-tree=${path.resolve(source)}`, 'ls-files', '--others', '--exclude-standard', '-z']);
|
|
43
|
+
|
|
44
|
+
return output.split('\0').filter(Boolean);
|
|
45
|
+
} finally {
|
|
46
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function snapshot(source: string, target: string, files: string[]): Promise<void> {
|
|
51
|
+
await fs.mkdir(target, { recursive: true });
|
|
52
|
+
|
|
53
|
+
for (const name of files) {
|
|
54
|
+
if (!isProjectPath(name)) throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Invalid native patch path: ${name}.`);
|
|
55
|
+
|
|
56
|
+
await HarmonyPaths.resolveHarmonyPath(source, name);
|
|
57
|
+
const from = path.join(source, name);
|
|
58
|
+
const to = path.join(target, name);
|
|
59
|
+
let stat;
|
|
60
|
+
try {
|
|
61
|
+
stat = await fs.lstat(from);
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
if (cause.code === 'ENOENT') continue;
|
|
64
|
+
|
|
65
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Cannot inspect ${from}.`, { cause, file: from });
|
|
66
|
+
}
|
|
67
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch source must be a file: ${from}.`);
|
|
68
|
+
|
|
69
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
70
|
+
await fs.cp(from, to, { verbatimSymlinks: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function changedFiles(root: string, patch: string): Promise<string[]> {
|
|
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 HarmonyPatchError(
|
|
80
|
+
'ERR_HARMONY_PATCH_FAILED',
|
|
81
|
+
'Patch paths must remain inside the Harmony project and cannot modify Git metadata.'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return match[2];
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return [...new Set(files)];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function generatePatchAsync(baseline: string, current: string): Promise<string> {
|
|
92
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'harmony-patch-diff-'));
|
|
93
|
+
try {
|
|
94
|
+
await git(staging, ['init', '--quiet']);
|
|
95
|
+
const native = path.join(staging, 'harmony');
|
|
96
|
+
const files = await listFiles(baseline);
|
|
97
|
+
await snapshot(baseline, native, files);
|
|
98
|
+
|
|
99
|
+
// Temporary dependency paths must not appear in patches.
|
|
100
|
+
const root = await fs.realpath(path.dirname(baseline));
|
|
101
|
+
const file = path.join(root, '.expo/harmony/autolinking.json');
|
|
102
|
+
let manifest: string | undefined;
|
|
103
|
+
try {
|
|
104
|
+
manifest = await fs.readFile(file, 'utf8');
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (cause.code !== 'ENOENT') {
|
|
107
|
+
throw new HarmonyPatchError(
|
|
108
|
+
cause.code || 'ERR_HARMONY_PATCH_FAILED',
|
|
109
|
+
cause.message || `Cannot read autolinking manifest ${file}.`,
|
|
110
|
+
{ cause, file, operation: 'patch-project' }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (manifest) {
|
|
116
|
+
const file = path.join(native, HarmonyPaths.HARMONY_PATHS.rootOhPackage);
|
|
117
|
+
const canonical = canonicalizeAutolinkingArtifacts({
|
|
118
|
+
manifestSource: manifest,
|
|
119
|
+
ohPackageSource: await fs.readFile(file, 'utf8'),
|
|
120
|
+
generatedProjectRoot: root,
|
|
121
|
+
canonicalProjectRoot: await fs.realpath(path.dirname(current)),
|
|
122
|
+
harmonyProjectPath: await fs.realpath(current),
|
|
123
|
+
});
|
|
124
|
+
await fs.writeFile(file, canonical.ohPackageSource);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await git(staging, ['add', '-f', '--all', '--', 'harmony']);
|
|
128
|
+
await fs.rm(native, { recursive: true });
|
|
129
|
+
await snapshot(current, native, [...new Set([...files, ...await listFiles(current)])]);
|
|
130
|
+
|
|
131
|
+
// Intent-to-add includes new text and binary files without changing baseline entries.
|
|
132
|
+
const untracked = (await git(staging, ['ls-files', '--others', '-z', '--', 'harmony'])).split('\0').filter(Boolean);
|
|
133
|
+
if (untracked.length) await git(staging, ['add', '-f', '-N', '--', ...untracked]);
|
|
134
|
+
|
|
135
|
+
return await git(staging, [
|
|
136
|
+
'diff', '--binary', '--ignore-space-at-eol', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', '--', 'harmony',
|
|
137
|
+
]);
|
|
138
|
+
} finally {
|
|
139
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface PatchResult {
|
|
144
|
+
files: string[];
|
|
145
|
+
created: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function getPatchChangedLinesAsync(file: string): Promise<number> {
|
|
149
|
+
const stats = await git(path.dirname(file), ['apply', '--numstat', file]);
|
|
150
|
+
|
|
151
|
+
return stats.split('\n').reduce((total, row) => {
|
|
152
|
+
const [added, deleted] = row.split('\t', 2).map(Number);
|
|
153
|
+
return total + (Number.isFinite(added) ? added : 0) + (Number.isFinite(deleted) ? deleted : 0);
|
|
154
|
+
}, 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function applyPatchAsync(root: string, content: string): Promise<PatchResult> {
|
|
158
|
+
if (!content.trim()) return { files: [], created: [] };
|
|
159
|
+
if (/^(?:old mode|new mode|new file mode|deleted file mode) (?!100644$|100755$|120000$)/mu.test(content)
|
|
160
|
+
|| /^(?:rename|copy) (?:from|to) /mu.test(content)) {
|
|
161
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Use file or symlink patches; regenerate renames as delete/add changes.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'harmony-patch-apply-'));
|
|
165
|
+
try {
|
|
166
|
+
await git(staging, ['init', '--quiet']);
|
|
167
|
+
const patch = path.join(staging, 'changes.patch');
|
|
168
|
+
await fs.writeFile(patch, content);
|
|
169
|
+
const files = await changedFiles(staging, patch);
|
|
170
|
+
const native = await HarmonyPaths.resolveHarmonyPath(root, 'harmony');
|
|
171
|
+
|
|
172
|
+
await snapshot(native, path.join(staging, 'harmony'), files);
|
|
173
|
+
|
|
174
|
+
const chunks = content.split(/(?=^diff --git )/mu).filter(chunk => chunk.startsWith('diff --git '));
|
|
175
|
+
if (!chunks.length) {
|
|
176
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Expected a Git patch generated by npx @expo-harmony/patch-project.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sections = new Map<string, string>();
|
|
180
|
+
for (const chunk of chunks) {
|
|
181
|
+
await fs.writeFile(patch, chunk);
|
|
182
|
+
const names = await changedFiles(staging, patch);
|
|
183
|
+
if (names.length !== 1) throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Each patch section must modify one file.');
|
|
184
|
+
|
|
185
|
+
sections.set(names[0], (sections.get(names[0]) ?? '') + chunk);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const created: string[] = [];
|
|
189
|
+
for (const [name, source] of sections) {
|
|
190
|
+
if (/^new file mode /mu.test(source) && !/^deleted file mode /mu.test(source)) created.push(name);
|
|
191
|
+
await fs.writeFile(patch, source);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await git(staging, ['apply', '--ignore-whitespace', '--check', patch]);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
// Other mods may regenerate only some files. Accept already-applied
|
|
197
|
+
// sections independently, while validating the complete patch in staging.
|
|
198
|
+
try {
|
|
199
|
+
await git(staging, ['apply', '--ignore-whitespace', '--reverse', '--check', patch]);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new HarmonyPatchError(cause.code, cause.message, {
|
|
202
|
+
cause, file: patch, operation: cause.operation,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await git(staging, ['apply', '--ignore-whitespace', '--binary', patch]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (sections.size !== files.length || files.some(file => !sections.has(file))) {
|
|
213
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', 'Patch section paths do not match its file summary.');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const writes: Array<{ target: string; content?: Uint8Array; link?: string; mode?: number }> = [];
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
const target = await HarmonyPaths.resolveHarmonyPath(native, file);
|
|
219
|
+
const staged = path.join(staging, 'harmony', file);
|
|
220
|
+
let stat;
|
|
221
|
+
try {
|
|
222
|
+
stat = await fs.lstat(staged);
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
if (cause.code !== 'ENOENT') {
|
|
225
|
+
throw new HarmonyPatchError(
|
|
226
|
+
cause.code || 'ERR_HARMONY_PATCH_FAILED',
|
|
227
|
+
cause.message || `Cannot inspect staged patch output ${file}.`,
|
|
228
|
+
{ cause, file: staged, operation: 'patch-project' }
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (stat?.isSymbolicLink()) {
|
|
234
|
+
const link = await fs.readlink(staged);
|
|
235
|
+
if (path.isAbsolute(link) || !HarmonyPaths.isInside(native, path.resolve(path.dirname(target), link))) {
|
|
236
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch symlink must remain inside the Harmony project: ${file}.`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await HarmonyPaths.resolveHarmonyPath(native, path.relative(native, path.resolve(path.dirname(target), link)));
|
|
240
|
+
writes.push({ target, link });
|
|
241
|
+
} else if (stat?.isFile()) {
|
|
242
|
+
writes.push({ target, content: Uint8Array.from(await fs.readFile(staged)), mode: stat.mode & 0o777 });
|
|
243
|
+
} else if (!stat) writes.push({ target });
|
|
244
|
+
else throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Patch output must be a file: ${file}.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const { target, content, link, mode } of writes) {
|
|
248
|
+
if (content) {
|
|
249
|
+
await atomicWrite(target, content);
|
|
250
|
+
await fs.chmod(target, mode);
|
|
251
|
+
} else {
|
|
252
|
+
await fs.rm(target, { force: true });
|
|
253
|
+
if (link !== undefined) {
|
|
254
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
255
|
+
await fs.symlink(link, target);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { files, created };
|
|
261
|
+
} finally {
|
|
262
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/patches.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { HarmonyPatchError } from './errors';
|
|
4
|
+
|
|
5
|
+
export async function getPatchFilesAsync(root: string): Promise<string[]> {
|
|
6
|
+
try {
|
|
7
|
+
return (await fs.readdir(root)).filter(name => name.startsWith('harmony') && name.endsWith('.patch')).sort();
|
|
8
|
+
} catch (cause) {
|
|
9
|
+
if (cause.code === 'ENOENT') return [];
|
|
10
|
+
|
|
11
|
+
throw new HarmonyPatchError('ERR_HARMONY_PATCH_FAILED', `Cannot read patch directory ${root}.`, { cause, file: root });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { createRunOncePlugin, HarmonyPaths, recordManagedFile, registerHarmonyConfigPlugin, type HarmonyConfigPlugin } from '@expo-harmony/config-plugins';
|
|
5
|
+
import { withProjectPatch } from '@expo-harmony/config-plugins/internal';
|
|
6
|
+
import { WarningAggregator, type ModPlatform } from '@expo/config-plugins';
|
|
7
|
+
|
|
8
|
+
import { HarmonyPatchError } from './errors';
|
|
9
|
+
import { applyPatchAsync, getPatchChangedLinesAsync } from './gitPatch';
|
|
10
|
+
import { getPatchFilesAsync } from './patches';
|
|
11
|
+
|
|
12
|
+
export interface PatchPluginProps {
|
|
13
|
+
patchRoot?: string;
|
|
14
|
+
changedLinesLimit?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const owner = '@expo-harmony/patch-project';
|
|
18
|
+
|
|
19
|
+
const withPatchPlugin: HarmonyConfigPlugin<PatchPluginProps | void> = (config, props) => {
|
|
20
|
+
const options = props || {};
|
|
21
|
+
const limit = options.changedLinesLimit ?? 300;
|
|
22
|
+
if ((options.patchRoot !== undefined && (typeof options.patchRoot !== 'string' || !options.patchRoot.trim()))
|
|
23
|
+
|| !Number.isFinite(limit) || limit < 0) {
|
|
24
|
+
throw new HarmonyPatchError('ERR_HARMONY_CONFIG_INVALID', 'patchRoot must be a non-empty directory path and changedLinesLimit must be a non-negative number.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerHarmonyConfigPlugin(config, owner);
|
|
28
|
+
|
|
29
|
+
return withProjectPatch(config, async (mod) => {
|
|
30
|
+
if (mod.modRequest.introspect || process.env.EXPO_HARMONY_SKIP_PATCHES === '1') return mod;
|
|
31
|
+
|
|
32
|
+
const root = mod.modRequest.projectRoot;
|
|
33
|
+
const directory = await HarmonyPaths.resolveHarmonyPath(root, options.patchRoot ?? 'cng-patches');
|
|
34
|
+
const files = await getPatchFilesAsync(directory);
|
|
35
|
+
if (!files.length) return mod;
|
|
36
|
+
|
|
37
|
+
const checksum = mod._internal?.templateChecksum;
|
|
38
|
+
const name = `harmony+${checksum}.patch`;
|
|
39
|
+
if (!checksum || !files.includes(name)) {
|
|
40
|
+
WarningAggregator.addWarningForPlatform('harmony' as ModPlatform, 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.`);
|
|
41
|
+
|
|
42
|
+
return mod;
|
|
43
|
+
}
|
|
44
|
+
if (files.length > 1) {
|
|
45
|
+
WarningAggregator.addWarningForPlatform('harmony' as ModPlatform, owner, `Multiple Harmony patches found; only ${name} will be applied.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const file = await HarmonyPaths.resolveHarmonyPath(directory, name);
|
|
49
|
+
const source = await fs.readFile(file, 'utf8');
|
|
50
|
+
if (!source.trim()) return mod;
|
|
51
|
+
|
|
52
|
+
const changed = await getPatchChangedLinesAsync(file);
|
|
53
|
+
if (changed > limit) {
|
|
54
|
+
WarningAggregator.addWarningForPlatform('harmony' as ModPlatform, owner, `${name} has ${changed} changed lines, exceeding the warning limit of ${limit}. Consider a config plugin for larger changes.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await applyPatchAsync(root, source);
|
|
58
|
+
registerHarmonyConfigPlugin(mod, owner, { files: result.created });
|
|
59
|
+
for (const relative of result.files) {
|
|
60
|
+
const file = path.join(mod.modRequest.platformProjectRoot, relative);
|
|
61
|
+
const managed = mod._internal?.harmonyManagedFiles?.find(item => item.path === `harmony/${relative}`);
|
|
62
|
+
recordManagedFile(mod, file, managed?.owner ?? (result.created.includes(relative) ? owner : 'patch'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mod;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default createRunOncePlugin(withPatchPlugin, owner);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"esModuleInterop": true,
|
|
4
|
+
"declaration": true,
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"module": "Node16",
|
|
7
|
+
"moduleResolution": "Node16",
|
|
8
|
+
"outDir": "build",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noImplicitAny": false,
|
|
13
|
+
"strictNullChecks": false,
|
|
14
|
+
"useUnknownInCatchVariables": false,
|
|
15
|
+
"target": "ES2022",
|
|
16
|
+
"types": ["node"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*.ts"],
|
|
19
|
+
"exclude": ["build"]
|
|
20
|
+
}
|