@orbital_mcp/patchrelay 0.1.0-alpha04 → 0.1.0-alpha05
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/.patchrelay-tools/build.gradle +22 -0
- package/.patchrelay-tools/settings.gradle +1 -0
- package/.patchrelay-tools/src/main/groovy/dev/patchrelay/gradle/FunctionsPlugin.groovy +328 -0
- package/{gradle-plugin → .patchrelay-tools}/src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy +226 -220
- package/.patchrelay-tools/src/main/kotlin/dev/patchrelay/compiler/PatchRelayCompilerRegistrar.kt +70 -0
- package/.patchrelay-tools/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
- package/.patchrelay-tools/src/main/resources/patchrelay/ComposeScreens.kt +139 -0
- package/README.md +249 -174
- package/package.json +2 -2
- package/src/main.mjs +511 -279
- package/src/project.mjs +196 -174
- package/gradle-plugin/build.gradle +0 -15
- package/gradle-plugin/settings.gradle +0 -1
package/src/main.mjs
CHANGED
|
@@ -1,279 +1,511 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, renameSync } from 'node:fs';
|
|
2
|
-
import { join, resolve, dirname } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
patchrelay
|
|
14
|
-
patchrelay
|
|
15
|
-
patchrelay
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, renameSync, mkdtempSync, rmSync, readdirSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, dirname } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { sha256, readJson, hostHash, inside, fingerprint, checkNative, compareFiles, emptyOutput, command, jdkTool, gradle, loadBaseline, signedManifest, routeId, reserveVersion, verifiedEnvelope, updateCatalog, signPayload, validateUrl, baselineFiles, checkFunctions } from './project.mjs';
|
|
9
|
+
|
|
10
|
+
const configName = 'patchrelay.config.json';
|
|
11
|
+
const help = `PatchRelay — build and sign patches for prepared Android projects
|
|
12
|
+
|
|
13
|
+
patchrelay init Configure this project once
|
|
14
|
+
patchrelay baseline Build and save a named base release
|
|
15
|
+
patchrelay compare Compare current files with a selected baseline
|
|
16
|
+
patchrelay compare --diff Also show actual source-line differences (requires Git)
|
|
17
|
+
patchrelay patch [--github] Build, sign, and publish assets to a GitHub Release
|
|
18
|
+
patchrelay catalog Update a signed app/channel catalog locally
|
|
19
|
+
patchrelay baseline --from REF Recover an explicitly selected old release
|
|
20
|
+
|
|
21
|
+
Options: --name NAME, --baseline PATH, --version NUMBER, --url HTTPS_URL, --github,
|
|
22
|
+
--tag TAG, --out PATH, --from REF, --apk PATH
|
|
23
|
+
Catalog options: --patch PATH, --catalog PATH_OR_new, --channel NAME, --url MANIFEST_URL
|
|
24
|
+
|
|
25
|
+
Configured setup selects the current APK baseline and next version. Use --github to publish. No Python required.
|
|
26
|
+
Requires Node 22+, JDK 17, Android SDK and a prepared PatchRelay screen module.`;
|
|
27
|
+
|
|
28
|
+
function argumentsFor(args) {
|
|
29
|
+
const [action = 'help', ...rest] = args;
|
|
30
|
+
const options = {};
|
|
31
|
+
const allowed = { init: [], baseline: ['name', 'out', 'from', 'apk'], compare: ['baseline', 'diff'], patch: ['baseline', 'version', 'url', 'tag', 'out', 'github'],
|
|
32
|
+
catalog: ['patch', 'catalog', 'channel', 'url', 'out'] };
|
|
33
|
+
if (['help', '--help', '-h', '--version'].includes(action)) return { action, options };
|
|
34
|
+
if (!allowed[action]) throw new Error(`Unknown command: ${action}. Run patchrelay --help.`);
|
|
35
|
+
if (rest.includes('--help')) return { action: 'help', options };
|
|
36
|
+
for (let i = 0; i < rest.length; i++) {
|
|
37
|
+
const key = rest[i].replace(/^--/, '');
|
|
38
|
+
if (!rest[i].startsWith('--') || !allowed[action].includes(key) || Object.hasOwn(options, key)) {
|
|
39
|
+
throw new Error(`Invalid option: ${rest[i]}. Run patchrelay --help.`);
|
|
40
|
+
}
|
|
41
|
+
if ((action === 'compare' && key === 'diff') || (action === 'patch' && key === 'github')) { options[key] = true; continue; }
|
|
42
|
+
if (!rest[i + 1] || rest[i + 1].startsWith('--')) throw new Error(`Missing value: ${rest[i]}. Run patchrelay --help.`);
|
|
43
|
+
options[key] = rest[++i];
|
|
44
|
+
}
|
|
45
|
+
if (options.apk && !options.from) throw new Error('--apk requires an explicit --from revision.');
|
|
46
|
+
return { action, options };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findRoot(start) {
|
|
50
|
+
let path = resolve(start);
|
|
51
|
+
while (true) {
|
|
52
|
+
if (existsSync(join(path, configName)) || existsSync(join(path, 'runtime.json'))) return path;
|
|
53
|
+
const parent = dirname(path);
|
|
54
|
+
if (parent === path) throw new Error('Run in a prepared Android project containing runtime.json. See the CLI integration guide.');
|
|
55
|
+
path = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function name(value) {
|
|
60
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$/.test(value)) throw new Error('Use a release name containing letters, numbers, dots, underscores or hyphens.');
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function project(root, fallback) {
|
|
65
|
+
const settings = existsSync(join(root, configName)) ? readJson(join(root, configName)) : fallback;
|
|
66
|
+
if (!settings || settings.format !== 1) throw new Error('Run patchrelay init first.');
|
|
67
|
+
const runtime = readJson(inside(root, settings.runtimeFile));
|
|
68
|
+
if (runtime.format !== 1 || runtime.screenApi !== 2 || !Array.isArray(runtime.nativeInputs) || !Array.isArray(runtime.screenIds) || !runtime.screenIds.length) throw new Error('Unsupported project runtime contract.');
|
|
69
|
+
return { root, settings, runtime };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nativeFiles(ctx) { return fingerprint(ctx.root, ctx.runtime.nativeInputs, ctx.runtime.textExtensions); }
|
|
73
|
+
function generatedFunctions(ctx) {
|
|
74
|
+
if (!ctx.settings.functionContractFile) throw new Error('Configure functionContractFile from dev.patchrelay.functions and build a new native baseline.');
|
|
75
|
+
const contract = readJson(inside(ctx.root, ctx.settings.functionContractFile));
|
|
76
|
+
checkFunctions(contract, contract);
|
|
77
|
+
return contract;
|
|
78
|
+
}
|
|
79
|
+
function screenFiles(ctx) { return fingerprint(ctx.root, ctx.settings.screenInputs, ctx.runtime.textExtensions); }
|
|
80
|
+
function saveJson(file, data) { writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { flag: 'wx' }); }
|
|
81
|
+
|
|
82
|
+
async function publishGitHubRelease(root, repository, branch, tag, version, files) {
|
|
83
|
+
let token = process.env.GITHUB_TOKEN;
|
|
84
|
+
if (!token) {
|
|
85
|
+
const credential = spawnSync('git', ['credential', 'fill'], { cwd: root, encoding: 'utf8', input: 'protocol=https\nhost=github.com\n\n' });
|
|
86
|
+
token = credential.stdout?.split(/\r?\n/).find(line => line.startsWith('password='))?.slice(9);
|
|
87
|
+
}
|
|
88
|
+
if (!token) throw new Error('GitHub sign-in required. Sign in with Git Credential Manager or set GITHUB_TOKEN.');
|
|
89
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' };
|
|
90
|
+
const api = `https://api.github.com/repos/${repository}/releases`;
|
|
91
|
+
const request = async (url, options) => {
|
|
92
|
+
const response = await fetch(url, { ...options, headers: { ...headers, ...options?.headers } });
|
|
93
|
+
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
94
|
+
return response.status === 204 ? null : response.json();
|
|
95
|
+
};
|
|
96
|
+
const release = await request(api, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ tag_name: tag, target_commitish: branch, name: `PatchRelay patch ${version}`,
|
|
98
|
+
body: `PatchRelay signed patch ${version}. Includes screens.js, patch.json, and the updated catalog.json.`, draft: true, prerelease: false }) });
|
|
99
|
+
const uploadUrl = release.upload_url.split('{')[0];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const asset = readFileSync(file);
|
|
102
|
+
await request(`${uploadUrl}?name=${encodeURIComponent(file.split(/[\\/]/).pop())}`, {
|
|
103
|
+
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: asset });
|
|
104
|
+
}
|
|
105
|
+
await request(release.url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ draft: false, make_latest: 'true' }) });
|
|
107
|
+
return release.html_url;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function replacePatchOutput(folder, expectedFolder) {
|
|
111
|
+
if (resolve(folder) !== resolve(expectedFolder)) throw new Error('Refusing to clean an unexpected patch output path.');
|
|
112
|
+
if (existsSync(folder)) {
|
|
113
|
+
if (lstatSync(folder).isSymbolicLink()) throw new Error('Patch output folder cannot be a symbolic link.');
|
|
114
|
+
const entries = readdirSync(folder, { withFileTypes: true });
|
|
115
|
+
if (entries.some(entry => entry.isSymbolicLink())) throw new Error('Patch output contains a symbolic link; move it before rebuilding.');
|
|
116
|
+
const marker = '.patchrelay-managed-output';
|
|
117
|
+
const marked = entries.some(entry => entry.name === marker);
|
|
118
|
+
if (marked) {
|
|
119
|
+
if (readFileSync(join(folder, marker), 'utf8') !== 'PatchRelay generated patch output\n' ||
|
|
120
|
+
entries.some(entry => !entry.isFile() || ![marker, 'screens.js', 'patch.json', 'catalog.json'].includes(entry.name))) {
|
|
121
|
+
throw new Error('Patch output contains unrecognized files; move them before rebuilding.');
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const oldTree = entries.every(route => route.isDirectory() && /^[a-f0-9]{64}$/.test(route.name) &&
|
|
125
|
+
readdirSync(join(folder, route.name), { withFileTypes: true }).every(version => version.isDirectory() && /^\d+$/.test(version.name) &&
|
|
126
|
+
readdirSync(join(folder, route.name, version.name), { withFileTypes: true }).every(file => file.isFile() && ['screens.js', 'patch.json', 'catalog.json'].includes(file.name))));
|
|
127
|
+
if (!oldTree) throw new Error('Patch output is not a PatchRelay-generated folder; move it before rebuilding.');
|
|
128
|
+
}
|
|
129
|
+
rmSync(folder, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(folder, { recursive: true });
|
|
132
|
+
writeFileSync(join(folder, '.patchrelay-managed-output'), 'PatchRelay generated patch output\n', { flag: 'wx' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function printComparison(label, changes) {
|
|
136
|
+
console.log(`${label}: ${changes.added.length} added, ${changes.modified.length} modified, ${changes.removed.length} removed`);
|
|
137
|
+
let count = 0;
|
|
138
|
+
for (const [kind, paths] of Object.entries(changes)) {
|
|
139
|
+
for (const path of paths) { console.log(` ${kind.toUpperCase().padEnd(8)} ${path}`); count++; }
|
|
140
|
+
}
|
|
141
|
+
if (!count) console.log(' No changes');
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function printSourceDiff(ctx, file, baseline, changes) {
|
|
146
|
+
if (!Object.values(changes).some(paths => paths.length)) return;
|
|
147
|
+
if (!baseline.sourcesSha256) throw new Error('--diff requires a baseline with a verified source archive.');
|
|
148
|
+
const archive = join(dirname(file), 'baseline-sources.zip');
|
|
149
|
+
const folder = mkdtempSync(join(tmpdir(), 'patchrelay-compare-'));
|
|
150
|
+
try {
|
|
151
|
+
const sources = join(folder, 'sources');
|
|
152
|
+
mkdirSync(sources);
|
|
153
|
+
const entries = command(ctx.root, jdkTool('jar'), ['-J-Dfile.encoding=UTF-8', '--list', '--file', archive], true).split(/\r?\n/).filter(Boolean);
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (entry.includes(':') || entry.startsWith('/') || entry.startsWith('\\') || entry.split(/[\\/]/).includes('..')) throw new Error('Unsafe baseline archive entry.');
|
|
156
|
+
inside(sources, entry);
|
|
157
|
+
}
|
|
158
|
+
command(sources, jdkTool('jar'), ['--extract', '--file', archive]);
|
|
159
|
+
const empty = join(folder, '.patchrelay-empty-file');
|
|
160
|
+
writeFileSync(empty, '');
|
|
161
|
+
for (const [kind, paths] of Object.entries(changes)) for (const path of paths) {
|
|
162
|
+
const oldFile = kind === 'added' ? empty : inside(sources, path);
|
|
163
|
+
const newFile = kind === 'removed' ? empty : inside(ctx.root, path);
|
|
164
|
+
if (!existsSync(oldFile) || !existsSync(newFile)) throw new Error(`Missing comparison source: ${path}`);
|
|
165
|
+
const result = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--no-color', '--', oldFile, newFile], {cwd: folder, encoding: 'utf8', shell: false, maxBuffer: 16 * 1024 * 1024});
|
|
166
|
+
if (result.error || ![0, 1].includes(result.status)) throw new Error(`Cannot compare ${path}; --diff requires Git. ${result.error?.message || result.stderr || ''}`);
|
|
167
|
+
console.log(`\n${kind.toUpperCase()} ${path}`);
|
|
168
|
+
console.log(result.stdout.replace(/^diff --git .*$/m, `diff --git baseline/${path} current/${path}`)
|
|
169
|
+
.replace(/^--- .*$/m, kind === 'added' ? '--- /dev/null' : `--- baseline/${path}`)
|
|
170
|
+
.replace(/^\+\+\+ .*$/m, kind === 'removed' ? '+++ /dev/null' : `+++ current/${path}`).trimEnd());
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
if (dirname(folder) !== tmpdir() || !folder.startsWith(join(tmpdir(), 'patchrelay-compare-'))) throw new Error('Unsafe comparison cleanup target');
|
|
174
|
+
rmSync(folder, {recursive: true, force: true});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function verifyBuild(ctx, expected) {
|
|
179
|
+
const source = readFileSync(inside(ctx.root, ctx.settings.buildConfigFile), 'utf8');
|
|
180
|
+
for (const [field, value] of Object.entries({ HOST_SHA256: expected, APPLICATION_ID: ctx.runtime.appId, BASE_VERSION: ctx.runtime.baseVersion })) {
|
|
181
|
+
let text = source;
|
|
182
|
+
if (field === 'HOST_SHA256') {
|
|
183
|
+
const generated = source.match(/\bHOST_SHA256 = ([\w.]+\.PatchRelayBuild)\.HOST_SHA256;/);
|
|
184
|
+
if (generated) {
|
|
185
|
+
const [module, tail] = ctx.settings.buildConfigFile.replaceAll('\\', '/').split('/build/generated/source/buildConfig/');
|
|
186
|
+
const variant = tail?.split('/')[0];
|
|
187
|
+
if (!variant) throw new Error('Configure the standard generated BuildConfig path for Gradle baseline integration.');
|
|
188
|
+
const qualified = generated[1].replaceAll('.', '/');
|
|
189
|
+
const candidates = [`${module}/build/generated/java/preparePatchRelay${variant[0].toUpperCase() + variant.slice(1)}/${qualified}.java`, `${module}/build/generated/patchrelay/${variant}/${qualified}.java`];
|
|
190
|
+
const file = candidates.map(path => inside(ctx.root, path)).find(existsSync);
|
|
191
|
+
if (!file) throw new Error('Missing generated PatchRelay fingerprint source. Build the selected native variant first.');
|
|
192
|
+
text = readFileSync(file, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const match = text.match(new RegExp(`\\b${field} = (?:new String\\()?("(?:[^"\\\\]|\\\\.)*")`));
|
|
196
|
+
if (!match || JSON.parse(match[1]) !== value) throw new Error(`Built APK ${field} does not match the release contract. Check host Gradle integration.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildBaseline(ctx, out) {
|
|
201
|
+
const { root, settings, runtime } = ctx;
|
|
202
|
+
emptyOutput(out);
|
|
203
|
+
gradle(root, settings.compileTasks);
|
|
204
|
+
const program = readFileSync(inside(root, settings.programFile));
|
|
205
|
+
if (!program.length || program.length > 2 * 1024 * 1024) throw new Error('Compiled screen program must be 1 byte–2 MiB.');
|
|
206
|
+
new TextDecoder('utf-8', { fatal: true }).decode(program);
|
|
207
|
+
// The bundled program is a native-build input; include it before hashing/building.
|
|
208
|
+
copyFileSync(inside(root, settings.programFile), inside(root, settings.baselineAsset));
|
|
209
|
+
const before = nativeFiles(ctx);
|
|
210
|
+
const sources = screenFiles(ctx);
|
|
211
|
+
gradle(root, settings.baseTasks);
|
|
212
|
+
checkNative(before, nativeFiles(ctx));
|
|
213
|
+
checkNative(sources, screenFiles(ctx));
|
|
214
|
+
verifyBuild(ctx, hostHash(before));
|
|
215
|
+
const apk = readFileSync(inside(root, settings.apkFile));
|
|
216
|
+
writeFileSync(join(out, 'screen-base.apk'), apk, { flag: 'wx' });
|
|
217
|
+
writeFileSync(join(out, 'baseline-screen.js'), program, { flag: 'wx' });
|
|
218
|
+
const staging = inside(root, `.patchrelay/work/${randomUUID()}/sources`);
|
|
219
|
+
mkdirSync(staging, { recursive: true });
|
|
220
|
+
for (const path of [...new Set([...Object.keys(before), ...Object.keys(sources)])]) {
|
|
221
|
+
if (path.split('/').some(part => ['.keys', 'local.properties', '.env'].includes(part)) || /\.(key|pem|p12|jks|keystore)$/i.test(path) ||
|
|
222
|
+
resolve(root, path) === resolve(root, settings.signingKey)) throw new Error('A private credential file is declared as a baseline input. Remove it from native/screen inputs.');
|
|
223
|
+
const target = inside(staging, path);
|
|
224
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
225
|
+
copyFileSync(inside(root, path), target);
|
|
226
|
+
}
|
|
227
|
+
checkNative(before, fingerprint(staging, Object.keys(before), runtime.textExtensions));
|
|
228
|
+
checkNative(sources, fingerprint(staging, Object.keys(sources), runtime.textExtensions));
|
|
229
|
+
const archive = join(out, 'baseline-sources.zip');
|
|
230
|
+
command(root, jdkTool('jar'), ['--create', '--file', archive, '-C', staging, '.']);
|
|
231
|
+
const baseline = { format: 3, appId: runtime.appId, baseVersion: runtime.baseVersion, screenApi: runtime.screenApi,
|
|
232
|
+
functionContract: generatedFunctions(ctx),
|
|
233
|
+
hostSha256: hostHash(before), screenIds: runtime.screenIds, nativeFiles: before, screenFiles: sources,
|
|
234
|
+
apkSha256: sha256(apk), programSha256: sha256(program), sourcesSha256: sha256(readFileSync(archive)) };
|
|
235
|
+
saveJson(join(out, 'baseline.json'), baseline);
|
|
236
|
+
return baseline;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function init(root, ask) {
|
|
240
|
+
if (existsSync(join(root, configName))) throw new Error('Project is already configured. Edit patchrelay.config.json to change settings.');
|
|
241
|
+
const runtime = readJson(join(root, 'runtime.json'));
|
|
242
|
+
const app = await ask('Android app module', existsSync(join(root, 'demo')) ? 'demo' : 'app');
|
|
243
|
+
const screens = await ask('Prepared Kotlin screen module', 'screens');
|
|
244
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(app) || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(screens)) throw new Error('Use simple Gradle module names.');
|
|
245
|
+
const repository = await ask('Patch GitHub repository (owner/repo), or "https" for another host');
|
|
246
|
+
if (repository !== 'https' && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error('Use owner/repo, or https.');
|
|
247
|
+
const patchBaseUrl = repository === 'https'
|
|
248
|
+
? validateUrl((await ask('HTTPS base URL where versioned patch files will be hosted')).replace(/\/+$/, ''))
|
|
249
|
+
: null;
|
|
250
|
+
const signingKey = await ask('Private signing-key file location (not key contents)', '.keys/patch-signing.key');
|
|
251
|
+
const catalogUrl = validateUrl(await ask('Stable HTTPS URL for this app\'s signed catalog'));
|
|
252
|
+
const channel = await ask('Update channel', 'production');
|
|
253
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(channel)) throw new Error('Use a lowercase channel name.');
|
|
254
|
+
const patchOutputDir = `${screens}/build/patchrelay`;
|
|
255
|
+
const defaultCatalogFile = `${patchOutputDir}/catalog.json`;
|
|
256
|
+
const previousCatalog = await ask('Existing local signed catalog path, or "new" for first catalog', 'new');
|
|
257
|
+
const catalogFile = previousCatalog === 'new' ? defaultCatalogFile : previousCatalog;
|
|
258
|
+
if (!catalogFile.endsWith('.json')) throw new Error('Catalog path must end with .json, or use "new" for the first catalog.');
|
|
259
|
+
if (previousCatalog !== 'new' && !existsSync(inside(root, catalogFile))) throw new Error(`Signed catalog file not found: ${catalogFile}. Download the current catalog or choose "new" only for the first release.`);
|
|
260
|
+
if (previousCatalog === 'new') inside(root, catalogFile);
|
|
261
|
+
const inputFile = async (label, fallback, suffix) => {
|
|
262
|
+
const path = await ask(label, fallback);
|
|
263
|
+
if (typeof path !== 'string' || !path.trim() || !path.endsWith(suffix)) throw new Error(`${label} requires a file path${suffix ? ` ending with ${suffix}` : ''}.`);
|
|
264
|
+
inside(root, path);
|
|
265
|
+
return path;
|
|
266
|
+
};
|
|
267
|
+
const programFile = await inputFile('Compiled Kotlin/JS program file', `${screens}/build/compileSync/js/main/productionExecutable/kotlin/${screens === 'screens' ? 'PatchRelayScreens' : screens}.js`, '.js');
|
|
268
|
+
const apkFile = await inputFile('Release APK file', `${app}/build/outputs/apk/release/${app}-release.apk`, '.apk');
|
|
269
|
+
const buildConfigFile = await inputFile('Generated release BuildConfig file', `${app}/build/generated/source/buildConfig/release/${runtime.appId.replaceAll('.', '/')}/BuildConfig.java`, '.java');
|
|
270
|
+
const publicKeyFile = await inputFile('Pinned public-key file', `${app}/patch-public-key.txt`, '');
|
|
271
|
+
const settings = { format: 1, runtimeFile: 'runtime.json', repository: repository === 'https' ? null : repository, patchBaseUrl, signingKey,
|
|
272
|
+
publicKeyFile, catalogUrl, catalogFile, channel,
|
|
273
|
+
baselinePointerFile: `${app}/build/outputs/patchrelay/release/apk-record.json`, patchOutputDir,
|
|
274
|
+
functionContractFile: `${screens}/build/generated/patchrelay/functions.json`,
|
|
275
|
+
screenInputs: [`${screens}/src/commonMain`], compileTasks: [`:${screens}:jvmTest`, `:${screens}:compileProductionExecutableKotlinJs`],
|
|
276
|
+
baseTasks: [`:${app}:assembleRelease`], programFile,
|
|
277
|
+
baselineAsset: `${app}/src/main/assets/baseline-screen.js`, apkFile, buildConfigFile,
|
|
278
|
+
outputDir: '.patchrelay/releases' };
|
|
279
|
+
const pluginSource = existsSync(fileURLToPath(new URL('../.patchrelay-tools/build.gradle', import.meta.url)))
|
|
280
|
+
? fileURLToPath(new URL('../.patchrelay-tools/', import.meta.url))
|
|
281
|
+
: fileURLToPath(new URL('../../.patchrelay-tools/', import.meta.url));
|
|
282
|
+
const pluginClass = 'src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy';
|
|
283
|
+
const pluginFolder = '.patchrelay-tools';
|
|
284
|
+
if (existsSync(join(root, pluginFolder)) && !existsSync(join(root, pluginFolder, pluginClass))) {
|
|
285
|
+
throw new Error('Plugin destination already contains unrelated files. Preserve them and choose a clean integration directory.');
|
|
286
|
+
}
|
|
287
|
+
if (!existsSync(join(root, pluginFolder))) cpSync(pluginSource, join(root, pluginFolder), {
|
|
288
|
+
recursive: true, errorOnExist: true, force: false,
|
|
289
|
+
filter: path => !path.endsWith('/build') && !path.endsWith('\\build') && !path.endsWith('/.gradle') && !path.endsWith('\\.gradle')
|
|
290
|
+
});
|
|
291
|
+
if (Array.isArray(runtime.nativeInputs)) {
|
|
292
|
+
for (const path of [`${pluginFolder}/src/main`, `${pluginFolder}/build.gradle`, `${pluginFolder}/settings.gradle`]) {
|
|
293
|
+
if (!runtime.nativeInputs.includes(path)) runtime.nativeInputs.push(path);
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(join(root, 'runtime.json'), JSON.stringify(runtime, null, 2) + '\n');
|
|
296
|
+
}
|
|
297
|
+
saveJson(join(root, configName), settings);
|
|
298
|
+
const ignoreFile = join(root, '.gitignore');
|
|
299
|
+
let ignored = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : '';
|
|
300
|
+
for (const rule of ['.patchrelay/', '.keys/', 'local.properties']) {
|
|
301
|
+
if (!ignored.split(/\r?\n/).includes(rule)) ignored += `${ignored.endsWith('\n') || !ignored ? '' : '\n'}${rule}\n`;
|
|
302
|
+
}
|
|
303
|
+
writeFileSync(ignoreFile, ignored);
|
|
304
|
+
const localFile = join(root, 'local.properties');
|
|
305
|
+
const local = existsSync(localFile) ? readFileSync(localFile, 'utf8') : '';
|
|
306
|
+
writeFileSync(localFile, local + (local.endsWith('\n') || !local ? '' : '\n') + `patchrelay.catalogUrl=${catalogUrl}\npatchrelay.channel=${channel}\n`);
|
|
307
|
+
console.log('Saved patchrelay.config.json. Review module paths before building. Keep private keys and .patchrelay/ out of Git.');
|
|
308
|
+
console.log(`Kotlin sources: ${screens}/src/commonMain (all files recursively; no individual file list needed).\nGenerated upload names: screens.js (program), patch.json (signed manifest), catalog.json (signed discovery catalog).\nKeep APKs, baseline backups, source archives and private signing keys off patch hosting. Patch and catalog commands print exact upload paths.`);
|
|
309
|
+
console.log(`Shared module: apply id("dev.patchrelay.functions"), and add the shared JVM module as an Android dependency. Use generated PatchRelayFunctions hooks.\nGradle hook: add includeBuild("${pluginFolder}") inside settings pluginManagement, and id("dev.patchrelay.baseline") in the app plugins block. Commit the plugin sources/config with your native release.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function recover(ctx, ref, original, out) {
|
|
313
|
+
if (!ref || ref.startsWith('-')) throw new Error('Choose an explicit commit, tag or branch.');
|
|
314
|
+
const expected = sha256(readFileSync(original));
|
|
315
|
+
const commit = command(ctx.root, 'git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], true);
|
|
316
|
+
const work = inside(ctx.root, `.patchrelay/recovery/${randomUUID()}`);
|
|
317
|
+
mkdirSync(work, { recursive: true });
|
|
318
|
+
const checkout = join(work, 'checkout');
|
|
319
|
+
command(ctx.root, 'git', ['worktree', 'add', '--detach', checkout, commit]);
|
|
320
|
+
console.log(`Recovery checkout: ${checkout}`);
|
|
321
|
+
const candidate = join(work, 'candidate');
|
|
322
|
+
const baseline = buildBaseline(project(checkout, ctx.settings), candidate);
|
|
323
|
+
if (baseline.apkSha256 !== expected) {
|
|
324
|
+
const file = join(candidate, 'baseline.json');
|
|
325
|
+
// Failed candidates must not look like accepted release records.
|
|
326
|
+
renameSync(file, join(candidate, 'unverified-baseline.json'));
|
|
327
|
+
throw new Error('Rebuilt APK differs from the supplied original APK. Recovery rejected; current checkout is unchanged.');
|
|
328
|
+
}
|
|
329
|
+
baseline.recoveredFromCommit = commit;
|
|
330
|
+
writeFileSync(join(candidate, 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n');
|
|
331
|
+
emptyOutput(out);
|
|
332
|
+
cpSync(candidate, out, { recursive: true, force: false, errorOnExist: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function main(args = process.argv.slice(2), cwd = process.cwd(), input) {
|
|
336
|
+
const { action, options } = argumentsFor(args);
|
|
337
|
+
if (action === '--version') { console.log('0.1.0-alpha05'); return; }
|
|
338
|
+
if (['help', '--help', '-h'].includes(action)) { console.log(help); return; }
|
|
339
|
+
let terminal;
|
|
340
|
+
const ask = input || (async (question, fallback) => {
|
|
341
|
+
if (!process.stdin.isTTY) throw new Error(`Missing value: ${question}. Supply flags or run in an interactive terminal.`);
|
|
342
|
+
terminal ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
343
|
+
const answer = (await terminal.question(`${question}${fallback ? ` [${fallback}]` : ''}: `)).trim() || fallback;
|
|
344
|
+
if (!answer) throw new Error(`${question} is required.`);
|
|
345
|
+
return answer;
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
const root = findRoot(cwd);
|
|
349
|
+
if (action === 'init') { await init(root, ask); return; }
|
|
350
|
+
const ctx = project(root);
|
|
351
|
+
if (action === 'baseline') {
|
|
352
|
+
const release = name(options.name || await ask('New baseline record name'));
|
|
353
|
+
const out = options.out ? resolve(root, options.out) : inside(root, `${ctx.settings.outputDir}/baselines/${release}`);
|
|
354
|
+
if (existsSync(out)) throw new Error('Output exists. Choose a new baseline name or output directory.');
|
|
355
|
+
if (options.from) {
|
|
356
|
+
const apk = resolve(root, options.apk || await ask('Original released APK file'));
|
|
357
|
+
await recover(ctx, options.from, apk, out);
|
|
358
|
+
} else buildBaseline(ctx, out);
|
|
359
|
+
console.log(`Baseline saved: ${out}\nBack up this entire directory privately.`);
|
|
360
|
+
} else if (action === 'catalog') {
|
|
361
|
+
const publicKey = readFileSync(inside(root, ctx.settings.publicKeyFile));
|
|
362
|
+
const patchFile = resolve(root, options.patch || await ask('Signed patch.json file path'));
|
|
363
|
+
const manifest = verifiedEnvelope(readFileSync(patchFile), publicKey);
|
|
364
|
+
if (manifest.appId !== ctx.runtime.appId) throw new Error('Selected patch belongs to another app.');
|
|
365
|
+
const bytes = readFileSync(join(dirname(patchFile), 'screens.js'));
|
|
366
|
+
if (sha256(bytes) !== manifest.sha256) throw new Error('Patch artifact does not match its signed manifest.');
|
|
367
|
+
const previousFile = options.catalog || await ask('Existing signed catalog path, or "new" for the first catalog');
|
|
368
|
+
const previous = previousFile === 'new' ? null : verifiedEnvelope(readFileSync(resolve(root, previousFile)), publicKey);
|
|
369
|
+
const channel = options.channel || await ask('Channel', 'production');
|
|
370
|
+
const url = validateUrl(options.url || await ask('Final HTTPS URL of this patch.json'));
|
|
371
|
+
const catalog = updateCatalog(previous, manifest, url, channel);
|
|
372
|
+
const envelope = signPayload(catalog, readFileSync(resolve(root, ctx.settings.signingKey)), publicKey);
|
|
373
|
+
const out = options.out ? resolve(root, options.out) : inside(root, `${ctx.settings.outputDir}/catalogs/${randomUUID()}`);
|
|
374
|
+
emptyOutput(out);
|
|
375
|
+
saveJson(join(out, 'catalog.json'), envelope);
|
|
376
|
+
console.log(`Signed catalog saved: ${join(out, 'catalog.json')}\nApp: ${catalog.appId}; channel: ${channel}; base builds: ${catalog.entries.length}\nNothing uploaded. Serve this catalog at the app's stable catalog URL after uploading its patch files.`);
|
|
377
|
+
console.log(`Upload in this order:\n1. ${join(dirname(patchFile), 'screens.js')} -> ${manifest.artifactUrl}\n2. ${patchFile} -> ${url}\n3. ${join(out, 'catalog.json')} -> ${ctx.settings.catalogUrl || 'the stable catalog URL configured in the native app'}\nKeep older hosted patch files: existing catalog routes may still reference them.`);
|
|
378
|
+
} else {
|
|
379
|
+
const baselineDir = inside(root, `${ctx.settings.outputDir}/baselines`);
|
|
380
|
+
if (!options.baseline && !ctx.settings.baselinePointerFile && existsSync(baselineDir)) {
|
|
381
|
+
console.log('Available records (choose explicitly):\n' + baselineFiles(baselineDir).map(file => ` ${file}`).join('\n'));
|
|
382
|
+
}
|
|
383
|
+
let selectedBaseline = options.baseline;
|
|
384
|
+
if (!selectedBaseline && ctx.settings.baselinePointerFile) {
|
|
385
|
+
const pointerPath = inside(root, ctx.settings.baselinePointerFile);
|
|
386
|
+
if (!existsSync(pointerPath)) throw new Error(`No current APK baseline is recorded. Build the configured release APK first; expected ${pointerPath}.`);
|
|
387
|
+
const pointer = readJson(pointerPath);
|
|
388
|
+
if (typeof pointer.baselineFile !== 'string') throw new Error(`Invalid baseline pointer: ${pointerPath}`);
|
|
389
|
+
selectedBaseline = pointer.baselineFile;
|
|
390
|
+
}
|
|
391
|
+
const file = resolve(root, selectedBaseline || await ask('Enter one of the baseline.json paths listed above'));
|
|
392
|
+
if (!existsSync(file)) throw new Error(`Baseline JSON not found: ${file}. Choose a listed baseline.json path; "new" is only valid for the first catalog.`);
|
|
393
|
+
const baseline = loadBaseline(file);
|
|
394
|
+
if (baseline.appId !== ctx.runtime.appId || baseline.baseVersion !== ctx.runtime.baseVersion || baseline.screenApi !== ctx.runtime.screenApi ||
|
|
395
|
+
JSON.stringify(baseline.screenIds) !== JSON.stringify(ctx.runtime.screenIds)) throw new Error('Selected baseline belongs to a different app/base/screen contract.');
|
|
396
|
+
checkFunctions(baseline.functionContract, baseline.functionContract);
|
|
397
|
+
const missingNative = ctx.runtime.nativeInputs.filter(path => !existsSync(inside(root, path)));
|
|
398
|
+
const missingScreens = ctx.settings.screenInputs.filter(path => !existsSync(inside(root, path)));
|
|
399
|
+
const currentNative = action === 'compare' ? fingerprint(root, ctx.runtime.nativeInputs.filter(path => !missingNative.includes(path)), ctx.runtime.textExtensions) : nativeFiles(ctx);
|
|
400
|
+
const currentScreens = action === 'compare' ? fingerprint(root, ctx.settings.screenInputs.filter(path => !missingScreens.includes(path)), ctx.runtime.textExtensions) : screenFiles(ctx);
|
|
401
|
+
console.log(`Baseline: ${file}\nApp: ${baseline.appId}; base: ${baseline.baseVersion}\nNative fingerprint: ${baseline.hostSha256}`);
|
|
402
|
+
const nativeDiff = compareFiles(baseline.nativeFiles, currentNative);
|
|
403
|
+
const screenDiff = compareFiles(baseline.screenFiles, currentScreens);
|
|
404
|
+
const nativeChanges = printComparison('Native/build files (new APK required when changed)', nativeDiff);
|
|
405
|
+
const screenChanges = printComparison('Supported Kotlin/screen files', screenDiff);
|
|
406
|
+
if (action === 'compare') {
|
|
407
|
+
for (const path of [...missingNative, ...missingScreens]) console.log(` MISSING configured input: ${path}`);
|
|
408
|
+
console.log(nativeChanges ? 'New APK baseline required before patching.' : missingNative.length || missingScreens.length ? 'Fix missing configured inputs before patching.' : 'Native inputs match. Function/signature compatibility is checked during patch compilation.');
|
|
409
|
+
console.log('Comparison uses configured baseline inputs, not every repository file. No build, signing or version reservation performed.');
|
|
410
|
+
if (options.diff) {
|
|
411
|
+
printSourceDiff(ctx, file, baseline, compareFiles({...baseline.nativeFiles, ...baseline.screenFiles}, {...currentNative, ...currentScreens}));
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
checkNative(baseline.nativeFiles, currentNative);
|
|
416
|
+
if (!screenChanges) throw new Error('No supported screen changes found.');
|
|
417
|
+
const configuredCatalog = ctx.settings.catalogFile && ctx.settings.catalogUrl;
|
|
418
|
+
const git = (...args) => {
|
|
419
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
420
|
+
if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'Git command failed').trim());
|
|
421
|
+
return result.stdout.trim();
|
|
422
|
+
};
|
|
423
|
+
const branch = options.github ? git('branch', '--show-current') : null;
|
|
424
|
+
if (options.github && !branch) throw new Error('--github requires a checked-out branch.');
|
|
425
|
+
let githubRepository;
|
|
426
|
+
if (options.github) {
|
|
427
|
+
const remote = git('remote', 'get-url', 'origin');
|
|
428
|
+
const match = remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
429
|
+
if (!match) throw new Error('--github needs an origin remote pointing to GitHub.');
|
|
430
|
+
if (ctx.settings.repository && ctx.settings.repository.toLowerCase() !== match[1].toLowerCase()) {
|
|
431
|
+
throw new Error(`Configured repository ${ctx.settings.repository} does not match origin ${match[1]}.`);
|
|
432
|
+
}
|
|
433
|
+
githubRepository = match[1];
|
|
434
|
+
if (!ctx.settings.catalogFile || options.url || options.out) throw new Error('--github needs a configured catalog and default output/URL settings.');
|
|
435
|
+
}
|
|
436
|
+
const releaseBase = options.github ? `https://github.com/${githubRepository}/releases/download` : null;
|
|
437
|
+
if (options.github) {
|
|
438
|
+
ctx.settings.patchBaseUrl = releaseBase;
|
|
439
|
+
ctx.settings.catalogUrl = `https://github.com/${githubRepository}/releases/latest/download/catalog.json`;
|
|
440
|
+
}
|
|
441
|
+
const publicKey = readFileSync(inside(root, ctx.settings.publicKeyFile));
|
|
442
|
+
const catalogFile = configuredCatalog ? inside(root, ctx.settings.catalogFile) : null;
|
|
443
|
+
const previousCatalog = catalogFile && existsSync(catalogFile) ? verifiedEnvelope(readFileSync(catalogFile), publicKey) : null;
|
|
444
|
+
const catalogChannel = ctx.settings.channel || 'production';
|
|
445
|
+
const priorRoute = previousCatalog?.entries?.find(entry => entry.baseVersion === baseline.baseVersion && entry.hostSha256 === baseline.hostSha256);
|
|
446
|
+
const versionRecord = inside(root, `.patchrelay/versions/${routeId(baseline)}.json`);
|
|
447
|
+
const versionData = existsSync(versionRecord) ? readJson(versionRecord) : null;
|
|
448
|
+
if (versionData && (versionData.format !== 1 || !Number.isSafeInteger(versionData.highest) || versionData.highest <= 0)) throw new Error('Invalid local patch version record. Restore its backup.');
|
|
449
|
+
const nextVersion = Math.max(versionData?.highest || 0, priorRoute?.version || 0) + 1;
|
|
450
|
+
const version = Number(options.version || (configuredCatalog ? nextVersion : await ask('Patch version (positive integer)')));
|
|
451
|
+
if (!Number.isSafeInteger(version) || version <= 0) throw new Error('Patch version must be a positive safe integer.');
|
|
452
|
+
const route = routeId(baseline);
|
|
453
|
+
const tag = name(options.tag || `patch-${route.slice(0, 24)}-${version}`);
|
|
454
|
+
const patchBase = ctx.settings.patchBaseUrl?.replace(/\/+$/, '');
|
|
455
|
+
const url = options.github
|
|
456
|
+
? `${releaseBase}/${tag}/screens.js`
|
|
457
|
+
: options.url || (ctx.settings.repository
|
|
458
|
+
? `https://github.com/${ctx.settings.repository}/releases/download/${tag}/screens.js`
|
|
459
|
+
: patchBase ? `${patchBase}/${tag}/screens.js` : await ask('Final HTTPS URL for screens.js'));
|
|
460
|
+
const patchOutputDir = ctx.settings.patchOutputDir;
|
|
461
|
+
const out = options.out ? resolve(root, options.out) : inside(root, patchOutputDir
|
|
462
|
+
? `${patchOutputDir}/patches`
|
|
463
|
+
: `${ctx.settings.outputDir}/patches/${route}/${version}`);
|
|
464
|
+
if (options.out && existsSync(out)) throw new Error(`Output already exists: ${out}. Choose a new output directory.`);
|
|
465
|
+
gradle(root, ctx.settings.compileTasks);
|
|
466
|
+
checkNative(currentNative, nativeFiles(ctx));
|
|
467
|
+
checkNative(currentScreens, screenFiles(ctx));
|
|
468
|
+
checkFunctions(baseline.functionContract, generatedFunctions(ctx));
|
|
469
|
+
const bytes = readFileSync(inside(root, ctx.settings.programFile));
|
|
470
|
+
if (sha256(bytes) === baseline.programSha256) throw new Error('Compiled program is unchanged.');
|
|
471
|
+
if (!ctx.settings.publicKeyFile) throw new Error('Configure publicKeyFile to match the key pinned in your app.');
|
|
472
|
+
const envelope = signedManifest(baseline, version, url, bytes, readFileSync(resolve(root, ctx.settings.signingKey)),
|
|
473
|
+
publicKey);
|
|
474
|
+
const patchManifestUrl = url.replace(/\/screens\.js$/, '/patch.json');
|
|
475
|
+
const updatedCatalog = configuredCatalog
|
|
476
|
+
? signPayload(updateCatalog(previousCatalog, verifiedEnvelope(Buffer.from(JSON.stringify(envelope)), publicKey), patchManifestUrl, catalogChannel),
|
|
477
|
+
readFileSync(resolve(root, ctx.settings.signingKey)), publicKey)
|
|
478
|
+
: null;
|
|
479
|
+
if (updatedCatalog && !url.endsWith('/screens.js')) throw new Error('Configured catalog publishing requires a screens.js URL so the signed patch.json URL can be derived.');
|
|
480
|
+
reserveVersion(root, baseline, version, () => {
|
|
481
|
+
if (!options.out && patchOutputDir) replacePatchOutput(out, inside(root, `${patchOutputDir}/patches`));
|
|
482
|
+
else emptyOutput(out);
|
|
483
|
+
writeFileSync(join(out, 'screens.js'), bytes, { flag: 'wx' });
|
|
484
|
+
saveJson(join(out, 'patch.json'), envelope);
|
|
485
|
+
if (updatedCatalog) {
|
|
486
|
+
saveJson(join(out, 'catalog.json'), updatedCatalog);
|
|
487
|
+
mkdirSync(dirname(catalogFile), { recursive: true });
|
|
488
|
+
const temporaryCatalog = `${catalogFile}.${randomUUID()}.tmp`;
|
|
489
|
+
writeFileSync(temporaryCatalog, JSON.stringify(updatedCatalog, null, 2) + '\n', { flag: 'wx', flush: true });
|
|
490
|
+
renameSync(temporaryCatalog, catalogFile);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
let releaseUrl;
|
|
494
|
+
if (options.github) {
|
|
495
|
+
const localFile = join(root, 'local.properties');
|
|
496
|
+
const local = existsSync(localFile) ? readFileSync(localFile, 'utf8') : '';
|
|
497
|
+
const properties = local.split(/\r?\n/).filter(line => !/^patchrelay\.(catalogUrl|channel)=/.test(line));
|
|
498
|
+
properties.push(`patchrelay.catalogUrl=${ctx.settings.catalogUrl}`, `patchrelay.channel=${catalogChannel}`);
|
|
499
|
+
writeFileSync(localFile, properties.filter(Boolean).join('\n') + '\n');
|
|
500
|
+
releaseUrl = await publishGitHubRelease(root, githubRepository, branch, tag, version,
|
|
501
|
+
['screens.js', 'patch.json', 'catalog.json'].map(file => join(out, file)));
|
|
502
|
+
}
|
|
503
|
+
console.log(`Signed patch saved: ${out}${options.github ? '' : `\nNothing uploaded. Target release tag: ${tag}`}`);
|
|
504
|
+
console.log(options.github
|
|
505
|
+
? `Patch assets published: ${releaseUrl}\nApp catalog: ${ctx.settings.catalogUrl}`
|
|
506
|
+
: updatedCatalog
|
|
507
|
+
? `Upload these files in order:\n1. ${join(out, 'screens.js')} -> ${url}\n2. ${join(out, 'patch.json')} -> ${patchManifestUrl}\n3. ${join(out, 'catalog.json')} -> ${ctx.settings.catalogUrl}\nLocal latest catalog saved: ${catalogFile}`
|
|
508
|
+
: `Patch upload files:\n${join(out, 'screens.js')} -> ${url}\n${join(out, 'patch.json')} -> the manifest HTTPS URL you will supply to patchrelay catalog\nNext: patchrelay catalog creates catalog.json and prints the complete upload checklist.`);
|
|
509
|
+
}
|
|
510
|
+
} finally { terminal?.close(); }
|
|
511
|
+
}
|