@orbital_mcp/patchrelay 0.0.0-stage → 0.1.0-alpha04

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/LICENSE ADDED
@@ -0,0 +1,71 @@
1
+ PatchRelay SDK License — Binary Use
2
+ Version 1.0
3
+
4
+ Copyright (c) 2026 the PatchRelay SDK copyright holders.
5
+ All rights reserved except as expressly granted below.
6
+
7
+ 1. Scope
8
+
9
+ This license applies to the PatchRelay Android SDK binaries and accompanying
10
+ command-line tools and SDK documentation distributed with this notice (the "SDK"). It does not grant
11
+ access to implementation source code or license separately identified third-party
12
+ software. Third-party components remain governed by their respective licenses.
13
+
14
+ 2. Permission
15
+
16
+ The copyright holders grant you a worldwide, non-exclusive, royalty-free license
17
+ to use the SDK to develop, test and operate your own applications, including free
18
+ and commercial applications, and to distribute the SDK as an integrated part of
19
+ those applications. You may shrink, optimize, obfuscate and package the SDK as
20
+ needed for that integration. You may keep copies in build caches and private
21
+ dependency repositories for your development and deployment.
22
+
23
+ Distribution of unmodified SDK releases through package registries or mirrors
24
+ authorized by the copyright holders is permitted. This license and applicable
25
+ third-party notices must accompany redistributed SDK copies; an application's
26
+ legal-notices screen or bundled notices file is sufficient.
27
+
28
+ Your application code and patches that you create remain yours. This license
29
+ does not require you to disclose their source code.
30
+
31
+ 3. Restrictions
32
+
33
+ You may not sell, sublicense or distribute the SDK as a standalone product,
34
+ publish modified SDK releases, remove ownership or license notices, or represent
35
+ the SDK as your own product without separate written permission. You may not
36
+ reverse engineer, decompile or disassemble the SDK except to the extent that
37
+ applicable law permits such activity despite this restriction. The permitted
38
+ build transformations in section 2 are not prohibited by this section.
39
+
40
+ No trademark rights or rights to implementation source code are granted.
41
+ All rights not expressly granted are reserved.
42
+
43
+ 4. Application and update responsibility
44
+
45
+ You are responsible for your applications, patches, signing keys, user disclosures,
46
+ data handling and compliance with applicable laws and distribution-platform rules.
47
+ Use of this SDK does not represent approval by Google Play or any other platform.
48
+
49
+ 5. Warranty and liability
50
+
51
+ THE SDK IS PROVIDED "AS IS", WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED,
52
+ INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
53
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE COPYRIGHT HOLDERS ARE NOT
54
+ LIABLE FOR CLAIMS, DAMAGES OR OTHER LIABILITY ARISING FROM THE SDK OR ITS USE,
55
+ WHETHER IN CONTRACT, TORT OR OTHERWISE. THESE LIMITATIONS DO NOT EXCLUDE RIGHTS
56
+ OR LIABILITY THAT APPLICABLE LAW DOES NOT ALLOW TO BE EXCLUDED.
57
+
58
+ 6. Termination
59
+
60
+ These permissions terminate if you materially breach this license and do not
61
+ remedy the breach within 30 days after receiving written notice from a copyright
62
+ holder. On termination, stop using and distributing the SDK. Termination does not
63
+ require end users to uninstall applications lawfully distributed before termination.
64
+
65
+ 7. Other terms
66
+
67
+ This is a proprietary binary-use license, not an open-source license. No support,
68
+ maintenance, future release or compatibility commitment is included. Changes to
69
+ license terms for later SDK versions do not change the terms supplied with a
70
+ previously received version. If a provision is unenforceable, the remaining
71
+ provisions continue to apply to the extent permitted by law.
package/README.md CHANGED
@@ -1,3 +1,174 @@
1
- # Temporary Holding Version
1
+ # PatchRelay CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An installable `patchrelay` command. No Python installation or Python commands.
4
+ Uses Node 22+ and the project's Gradle wrapper with JDK 17 and Android SDK 35.
5
+
6
+ Experimental alpha package: `@orbital_mcp/patchrelay`. The Android SDK is a
7
+ separate Maven dependency; installing it does not install this CLI.
8
+
9
+ ## Install a built package
10
+
11
+ ```sh
12
+ npm install --global /path/to/orbital_mcp-patchrelay-0.1.0-alpha04.tgz
13
+ patchrelay --help
14
+ ```
15
+
16
+ Public alpha installation:
17
+
18
+ ```sh
19
+ npm install --global @orbital_mcp/patchrelay@alpha
20
+ ```
21
+
22
+ Package page: https://www.npmjs.com/package/@orbital_mcp/patchrelay.
23
+ The `alpha` tag identifies an experimental release rather than a stable one.
24
+
25
+ Publisher setup requires `npm login`, verification that the account controls the
26
+ `@orbital_mcp` scope, and `npm publish --access public --tag alpha` from the CLI
27
+ package. Never paste npm credentials into this repository or an app. Ownership
28
+ and authentication must be established before advertising a working public link.
29
+
30
+ ## Use
31
+
32
+ Run inside your prepared Android project:
33
+
34
+ ```sh
35
+ patchrelay init
36
+ patchrelay baseline
37
+ # Edit patchable screen Kotlin.
38
+ patchrelay patch
39
+ patchrelay catalog
40
+ ```
41
+
42
+ `init` asks for module names, GitHub hosting repository (or HTTPS hosting) and
43
+ signing-key location, stable signed-catalog HTTPS URL and channel. It writes
44
+ `patchrelay.config.json` without key contents and appends catalog URL/channel
45
+ settings to `local.properties`, preserving existing SDK path settings.
46
+ It also copies the Gradle build plugin and prints the one-time settings/app plugin
47
+ hooks. Apply those hooks so normal `assembleRelease` and `bundleRelease` builds
48
+ automatically preserve matching baseline records; no extra baseline command is
49
+ then required. This repository's demo already has the hooks. The plugin remains
50
+ limited to prepared screen modules, not arbitrary Kotlin screen rewriting.
51
+ It adds `.patchrelay/` and `.keys/` to Git ignore rules without removing existing
52
+ rules. Custom signing-key locations still need their own ignore protection.
53
+ It does not generate/rotate keys, install SDK hooks, migrate ordinary native
54
+ screens or configure private-host authentication. Review generated JS/APK/
55
+ BuildConfig paths before building. A custom Kotlin/JS export name or Gradle
56
+ variant needs corresponding settings changes.
57
+
58
+ `baseline` asks for a unique record name, builds/tests the configured screen
59
+ module, bundles its program, builds the APK, checks the native fingerprint and
60
+ saves APK, program, compatibility JSON and private source ZIP under
61
+ `.patchrelay/releases/baselines/NAME`. It changes the configured bundled JS asset
62
+ as part of preparing that native build. Back up the entire baseline directory
63
+ privately. Local output is not cloud backup. Do not publish the source ZIP.
64
+
65
+ `patch` lists available baseline paths and asks you to choose one explicitly,
66
+ then asks for a positive patch version. It never selects a record automatically.
67
+ It rejects native/contract changes and altered source archives, compiles/tests
68
+ the full current supported screen program and signs `screens.js` plus `patch.json`.
69
+ Output directories are exclusive; prior releases are never overwritten.
70
+
71
+ GitHub artifact URLs use the configured repository and the convention
72
+ `patch-ROUTE_HASH-VERSION`; the route hash binds app ID, base version and exact
73
+ native fingerprint, avoiding collisions between apps/builds. `--tag` overrides
74
+ that release tag. For another HTTPS host,
75
+ the command asks for the actual final `screens.js` URL. Nothing is uploaded and
76
+ no hosted catalog is changed by these commands. The app's catalog endpoint is
77
+ configured separately in its native build; it is not the JS artifact URL.
78
+
79
+ Patch outputs are under `.patchrelay/releases/patches/ROUTE_HASH/VERSION`.
80
+ A locked, local version record under `.patchrelay/versions` prevents reusing or
81
+ lowering a version for the same exact base even with a different output folder.
82
+ A failed write after reservation consumes that version; choose a higher one.
83
+ Back up these records. They cannot detect versions published from another
84
+ machine unless you retain the latest published records/catalog. All channels
85
+ for an exact base share the version sequence.
86
+
87
+ ## Stable update discovery
88
+
89
+ `patchrelay catalog` asks for a signed patch manifest, the existing signed catalog
90
+ (or explicit `new` for the first one), channel and final manifest HTTPS URL.
91
+ It verifies signatures/artifact hashes, preserves routes for older bases and
92
+ updates only that patch's exact app/base route. It rejects a route downgrade,
93
+ wrong app/channel, ambiguous routes and more than 64 supported base builds.
94
+ It writes a new signed `catalog.json` locally, never overwriting hosted data.
95
+
96
+ Use one stable catalog URL per app/channel. Different apps can share one GitHub
97
+ repository while using separate catalogs, signing keys and immutable patch
98
+ releases. Upload/verify `screens.js` and its `patch.json` first, then replace
99
+ the hosted catalog. Keep every artifact still referenced by an older base.
100
+ GitHub asset replacement is not atomic; a brief failed check must leave the
101
+ current app working. Private-source hosting still needs accessible HTTPS patch
102
+ delivery or a separate authenticated gateway; GitHub credentials aren't embedded.
103
+
104
+ The prepared host calls `updates.stageCatalog(catalogUrl, channel)` off the UI
105
+ thread. The SDK verifies the catalog, selects the exact installed fingerprint,
106
+ then verifies the selected manifest and downloads only the latest complete
107
+ program. No match means no update. Existing version/signature checks still apply.
108
+ This supports skipping intermediate patches, not arbitrary data migrations.
109
+ The new demo checks once per process after healthy first rendering, silently;
110
+ the downloaded patch activates on a later process start, not mid-session.
111
+
112
+ Automation can supply explicit values:
113
+
114
+ ```sh
115
+ patchrelay baseline --name android-v1
116
+ patchrelay patch --baseline .patchrelay/releases/baselines/android-v1/baseline.json --version 1
117
+ ```
118
+
119
+ There is no default signing key material and no default publisher repository.
120
+ The configured `publicKeyFile` must contain the Base64 DER public key pinned in
121
+ the app; signing refuses a mismatched private key. The key file format is the existing PatchRelay Base64 DER PKCS#8 RSA private key
122
+ (minimum 3072 bits). Keep it out of Git and releases. The CLI signs locally;
123
+ private keys are never sent to Maven, npm or the patch host.
124
+
125
+ Normal Gradle baselines are stored at
126
+ `.patchrelay/releases/baselines/APP_ID/VARIANT/apk-ARTIFACT_HASH/` or
127
+ `aab-ARTIFACT_HASH/`. Select the desired `baseline.json` explicitly in `patch`.
128
+ Both formats work for patch compilation; AAB files themselves are not installed
129
+ by this tool. `patchrelay baseline` remains useful for a developer-named APK record.
130
+
131
+ ## Recover a lost record
132
+
133
+ ```sh
134
+ patchrelay baseline --from YOUR-COMMIT
135
+ ```
136
+
137
+ You supply the record name and original APK when asked. Recovery checks out only
138
+ that explicitly supplied revision into a detached worktree, using its project
139
+ settings when present or the current module-layout settings otherwise. It uses
140
+ the selected revision's runtime contract and sources. Current branch/edits remain
141
+ untouched. The rebuilt APK must match the original APK hash exactly. Different
142
+ signing/toolchain output is rejected; without a matching release, ship a new base.
143
+ Recovery worktrees and build diagnostics stay under `.patchrelay/recovery/`.
144
+
145
+ ## Project contract
146
+
147
+ This CLI is independent of this repository's Python tools, but it is **not an
148
+ automatic integration plugin for arbitrary Android apps**. Projects still need:
149
+
150
+ - Prepared Kotlin/JS screens and explicit native hooks.
151
+ - `runtime.json` format 1 / screen API 2 with app/base/screen identity, declared
152
+ native inputs and text-normalization extensions.
153
+ - Gradle generation of `HOST_SHA256`, `BASE_VERSION` and `APPLICATION_ID` in the
154
+ configured BuildConfig, following the existing sorted SHA-256 contract.
155
+ - An Android host using the SDK and configured trust keys/update endpoint.
156
+
157
+ The CLI does not scan ordinary Kotlin to discover patchable methods. Multiple
158
+ app/variant projects need separately reviewed configuration and release records.
159
+ Baseline selection remains manual. Signed catalog authoring/routing is implemented;
160
+ automatic remote uploads and arbitrary app integration are still pending.
161
+
162
+ ## Build this CLI from source
163
+
164
+ ```sh
165
+ cd cli
166
+ npm test
167
+ npm pack
168
+ ```
169
+
170
+ The npm tarball contains CLI runtime files, the Gradle build-plugin source,
171
+ documentation and license: no Android SDK/app implementation, signing keys,
172
+ baseline archives or private R8 mappings.
173
+ The CLI is JavaScript distributed to developers, so its runtime is inspectable.
174
+ Android SDK AAR obfuscation is a separate build step, not protection for CLI code.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/main.mjs';
3
+
4
+ main().catch(error => {
5
+ console.error(`PatchRelay: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,15 @@
1
+ plugins { id 'groovy'; id 'java-gradle-plugin' }
2
+ repositories { google(); mavenCentral() }
3
+ dependencies {
4
+ implementation localGroovy()
5
+ compileOnly 'com.android.tools.build:gradle-api:8.6.0'
6
+ }
7
+ java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
8
+ gradlePlugin {
9
+ plugins {
10
+ baseline {
11
+ id = 'dev.patchrelay.baseline'
12
+ implementationClass = 'dev.patchrelay.gradle.PatchRelayPlugin'
13
+ }
14
+ }
15
+ }
@@ -0,0 +1 @@
1
+ rootProject.name = 'patchrelay-gradle-plugin'
@@ -0,0 +1,220 @@
1
+ package dev.patchrelay.gradle
2
+
3
+ import com.android.build.api.artifact.SingleArtifact
4
+ import com.android.build.api.variant.BuildConfigField
5
+ import groovy.json.JsonOutput
6
+ import groovy.json.JsonSlurper
7
+ import org.gradle.api.*
8
+ import org.gradle.api.file.DirectoryProperty
9
+ import org.gradle.api.tasks.*
10
+ import java.nio.file.Files
11
+ import java.nio.file.StandardCopyOption
12
+ import java.security.MessageDigest
13
+ import java.util.zip.ZipEntry
14
+ import java.util.zip.ZipFile
15
+ import java.util.zip.ZipOutputStream
16
+
17
+ class Contract {
18
+ static String hash(byte[] bytes) { MessageDigest.getInstance('SHA-256').digest(bytes).encodeHex().toString() }
19
+ static String fileHash(File file) {
20
+ def digest = MessageDigest.getInstance('SHA-256')
21
+ file.withInputStream { input ->
22
+ byte[] buffer = new byte[8192]
23
+ int length
24
+ while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length)
25
+ }
26
+ digest.digest().encodeHex().toString()
27
+ }
28
+ static Map read(File file) { new JsonSlurper().parse(file) as Map }
29
+ static File inside(File root, String path) {
30
+ File file = new File(root, path).canonicalFile
31
+ if (!file.toPath().startsWith(root.canonicalFile.toPath())) throw new GradleException('PatchRelay input escapes project directory')
32
+ file
33
+ }
34
+ static Map fingerprint(File root, List paths, List extensions) {
35
+ def files = new TreeMap<String,String>()
36
+ paths.each { path ->
37
+ File input = inside(root, path.toString())
38
+ if (!input.exists()) throw new GradleException("Missing PatchRelay input: $path")
39
+ def visit = { File file ->
40
+ inside(root, root.toPath().relativize(file.toPath()).toString())
41
+ if (Files.isSymbolicLink(file.toPath())) throw new GradleException('PatchRelay source links are unsupported')
42
+ String digest = (extensions.any { file.name.endsWith(it.toString()) }
43
+ ? hash(file.getText('UTF-8').replace('\r\n','\n').getBytes('UTF-8')) : fileHash(file))
44
+ files[root.toPath().relativize(file.toPath()).toString().replace('\\','/')] = digest
45
+ }
46
+ if (input.isFile()) visit(input)
47
+ else input.eachFileRecurse { File file -> if (file.isFile()) visit(file) }
48
+ }
49
+ files
50
+ }
51
+ }
52
+
53
+ abstract class PrepareBaseline extends DefaultTask {
54
+ @OutputDirectory abstract DirectoryProperty getOutputDirectory()
55
+ @Internal Map options
56
+ @Internal def namespaceValue
57
+ @Internal def applicationId
58
+
59
+ @TaskAction void prepare() {
60
+ File root = project.rootDir
61
+ String namespace = namespaceValue.get()
62
+ String appId = applicationId.get()
63
+ Map runtime = Contract.read(Contract.inside(root, options.runtimeFile))
64
+ if (runtime.format != 1 || runtime.screenApi != 2 || runtime.appId != appId) throw new GradleException('PatchRelay runtime does not match this application variant')
65
+ File program = Contract.inside(root, options.programFile)
66
+ byte[] bytes = program.bytes
67
+ if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new GradleException('PatchRelay program must be 1 byte–2 MiB')
68
+ java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes))
69
+ File asset = Contract.inside(root, options.baselineAsset)
70
+ asset.parentFile.mkdirs()
71
+ if (!asset.isFile() || !Arrays.equals(asset.bytes, bytes)) Files.copy(program.toPath(), asset.toPath(), StandardCopyOption.REPLACE_EXISTING)
72
+ Map nativeFiles = Contract.fingerprint(root, runtime.nativeInputs, runtime.textExtensions)
73
+ Map screenFiles = Contract.fingerprint(root, options.screenInputs, runtime.textExtensions)
74
+ String host = Contract.hash(JsonOutput.toJson(nativeFiles).getBytes('UTF-8'))
75
+ File output = outputDirectory.get().asFile
76
+ output.mkdirs()
77
+ File source = new File(output, namespace.replace('.','/') + '/patchrelay/PatchRelayBuild.java')
78
+ source.parentFile.mkdirs()
79
+ source.setText("package ${namespace}.patchrelay;\npublic final class PatchRelayBuild { public static final String HOST_SHA256 = \"$host\"; }\n", 'UTF-8')
80
+ new File(output, 'baseline-screen.js').bytes = bytes
81
+ new File(output, 'contract.json').setText(JsonOutput.toJson([
82
+ format:3, appId:appId, baseVersion:runtime.baseVersion, screenApi:runtime.screenApi,
83
+ screenIds:runtime.screenIds, hostSha256:host, nativeFiles:nativeFiles,
84
+ screenFiles:screenFiles, programSha256:Contract.hash(bytes)
85
+ ]), 'UTF-8')
86
+ }
87
+ }
88
+
89
+ abstract class RecordBaseline extends DefaultTask {
90
+ @Internal Map options
91
+ @Internal def prepared
92
+ @Internal def artifactProvider
93
+ @Internal def artifactsLoader
94
+ @Internal String artifactType
95
+ @Internal String variantName
96
+
97
+ private void result(File destination, File artifact) {
98
+ File pointer = project.layout.buildDirectory.file("outputs/patchrelay/$variantName/$artifactType-record.json").get().asFile
99
+ pointer.parentFile.mkdirs()
100
+ pointer.setText(JsonOutput.toJson([
101
+ baselineFile:project.rootDir.toPath().relativize(new File(destination, 'baseline.json').toPath()).toString().replace('\\','/'),
102
+ artifactFile:project.rootDir.toPath().relativize(artifact.toPath()).toString().replace('\\','/')
103
+ ]) + '\n', 'UTF-8')
104
+ }
105
+
106
+ @TaskAction void record() {
107
+ File root = project.rootDir
108
+ File preparedDir = prepared.get().outputDirectory.get().asFile
109
+ Map baseline = Contract.read(new File(preparedDir, 'contract.json'))
110
+ Map runtime = Contract.read(Contract.inside(root, options.runtimeFile))
111
+ if (Contract.fingerprint(root, runtime.nativeInputs, runtime.textExtensions) != baseline.nativeFiles ||
112
+ Contract.fingerprint(root, options.screenInputs, runtime.textExtensions) != baseline.screenFiles) {
113
+ throw new GradleException('Sources changed during the build; no PatchRelay baseline recorded')
114
+ }
115
+ File artifact
116
+ if (artifactType == 'apk') {
117
+ def built = artifactsLoader.load(artifactProvider.get())
118
+ if (built == null || built.applicationId != baseline.appId || built.elements.size() != 1) throw new GradleException('PatchRelay currently requires one non-split APK per variant')
119
+ artifact = new File(built.elements.first().outputFile)
120
+ } else artifact = artifactProvider.get().asFile
121
+ String entryName = (artifactType == 'aab' ? 'base/' : '') + 'assets/baseline-screen.js'
122
+ new ZipFile(artifact).withCloseable { zip ->
123
+ def entry = zip.getEntry(entryName)
124
+ if (entry == null || entry.size < 1 || entry.size > 2 * 1024 * 1024) throw new GradleException('Built artifact has no supported bundled screen program')
125
+ byte[] embedded = zip.getInputStream(entry).withCloseable { it.readNBytes(2 * 1024 * 1024 + 1) }
126
+ if (Contract.hash(embedded) != baseline.programSha256) throw new GradleException('Built artifact contains a different screen program; baseline rejected')
127
+ }
128
+ String artifactHash = Contract.fileHash(artifact)
129
+ File destination = Contract.inside(root, ".patchrelay/releases/baselines/${baseline.appId}/$variantName/$artifactType-$artifactHash")
130
+ File existing = new File(destination, 'baseline.json')
131
+ if (existing.isFile()) {
132
+ Map saved = Contract.read(existing)
133
+ if (saved.artifactSha256 != artifactHash || saved.nativeFiles != baseline.nativeFiles || saved.screenFiles != baseline.screenFiles ||
134
+ !new File(destination, 'baseline-sources.zip').isFile() ||
135
+ Contract.fileHash(new File(destination, 'baseline-sources.zip')) != saved.sourcesSha256 ||
136
+ Contract.fileHash(new File(destination, "screen-base.$artifactType")) != artifactHash ||
137
+ Contract.hash(new File(destination, 'baseline-screen.js').bytes) != baseline.programSha256) {
138
+ throw new GradleException('Existing PatchRelay record differs or is damaged; restore it rather than overwriting')
139
+ }
140
+ logger.lifecycle("PatchRelay baseline already recorded: $destination")
141
+ result(destination, artifact)
142
+ return
143
+ }
144
+ if (destination.exists()) throw new GradleException('Incomplete PatchRelay output exists; preserve it and choose a fresh build')
145
+ destination.mkdirs()
146
+ Files.copy(artifact.toPath(), new File(destination, "screen-base.$artifactType").toPath())
147
+ new File(destination, 'baseline-screen.js').bytes = new File(preparedDir, 'baseline-screen.js').bytes
148
+ File archive = new File(destination, 'baseline-sources.zip')
149
+ new ZipOutputStream(new FileOutputStream(archive)).withCloseable { zip ->
150
+ (baseline.nativeFiles.keySet() + baseline.screenFiles.keySet()).toSorted().unique().each { String path ->
151
+ if (path.split('/').any { it in ['.keys', '.env', 'local.properties'] } || path ==~ /(?i).*\.(key|pem|p12|jks|keystore)$/) throw new GradleException('Private credentials cannot be baseline inputs')
152
+ File file = Contract.inside(root, path)
153
+ def entry = new ZipEntry(path); entry.time = 0
154
+ zip.putNextEntry(entry); file.withInputStream { it.transferTo(zip) }; zip.closeEntry()
155
+ }
156
+ }
157
+ baseline.artifactType = artifactType
158
+ baseline.artifactSha256 = artifactHash
159
+ baseline[artifactType == 'apk' ? 'apkSha256' : 'aabSha256'] = artifactHash
160
+ baseline.variant = variantName
161
+ baseline.sourcesSha256 = Contract.fileHash(archive)
162
+ Files.writeString(existing.toPath(), JsonOutput.prettyPrint(JsonOutput.toJson(baseline)) + '\n', java.nio.file.StandardOpenOption.CREATE_NEW)
163
+ result(destination, artifact)
164
+ logger.lifecycle("PatchRelay baseline recorded: $destination\nBack up the entire directory privately; patch selection remains explicit.")
165
+ }
166
+ }
167
+
168
+ class PatchRelayPlugin implements Plugin<Project> {
169
+ void apply(Project project) {
170
+ def extension = project.extensions.create('patchrelayBaseline', BaselineOptions, project)
171
+ project.plugins.withId('com.android.application') {
172
+ project.extensions.getByName('androidComponents').onVariants(
173
+ project.extensions.getByName('androidComponents').selector().withBuildType('release')) { variant ->
174
+ def options = extension.values()
175
+ String cap = variant.name.capitalize()
176
+ def prepare = project.tasks.register("preparePatchRelay$cap", PrepareBaseline) {
177
+ it.options = options; it.namespaceValue = variant.namespace; it.applicationId = variant.applicationId
178
+ outputDirectory.set(project.layout.buildDirectory.dir("generated/patchrelay/${variant.name}"))
179
+ dependsOn(options.compileTasks)
180
+ outputs.upToDateWhen { false }
181
+ notCompatibleWithConfigurationCache('PatchRelay snapshots declared project sources at execution time')
182
+ }
183
+ variant.sources.java.addGeneratedSourceDirectory(prepare, { it.outputDirectory })
184
+ variant.buildConfigFields.put('HOST_SHA256', project.providers.provider {
185
+ new BuildConfigField('String', "${variant.namespace.get()}.patchrelay.PatchRelayBuild.HOST_SHA256", null)
186
+ })
187
+ project.tasks.matching { it.name in ["merge${cap}Assets", "generate${cap}BuildConfig"] }.configureEach { dependsOn(prepare) }
188
+ ['apk','aab'].each { type ->
189
+ String buildName = type == 'apk' ? "assemble$cap" : "bundle$cap"
190
+ def provider = variant.artifacts.get(type == 'apk' ? SingleArtifact.APK.INSTANCE : SingleArtifact.BUNDLE.INSTANCE)
191
+ def record = project.tasks.register("recordPatchRelay${cap}${type.toUpperCase()}", RecordBaseline) {
192
+ it.options = options; it.prepared = prepare; artifactProvider = provider
193
+ artifactsLoader = variant.artifacts.getBuiltArtifactsLoader()
194
+ artifactType = type; variantName = variant.name
195
+ dependsOn(prepare, provider)
196
+ notCompatibleWithConfigurationCache('PatchRelay writes immutable release records outside build/')
197
+ onlyIf {
198
+ def build = project.tasks.findByName(buildName)
199
+ build != null && build.state.executed && build.state.failure == null
200
+ }
201
+ }
202
+ project.tasks.matching { it.name == buildName }.configureEach { build ->
203
+ build.finalizedBy(record)
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ class BaselineOptions {
212
+ final Project project
213
+ String runtimeFile = 'runtime.json'
214
+ String programFile = 'screens/build/compileSync/js/main/productionExecutable/kotlin/PatchRelayScreens.js'
215
+ String baselineAsset
216
+ List<String> screenInputs = ['screens/src/commonMain']
217
+ List<String> compileTasks = [':screens:jvmTest', ':screens:compileProductionExecutableKotlinJs']
218
+ BaselineOptions(Project project) { this.project = project; baselineAsset = project.rootDir.toPath().relativize(new File(project.projectDir, 'src/main/assets/baseline-screen.js').toPath()).toString().replace('\\','/') }
219
+ Map values() { [runtimeFile:runtimeFile, programFile:programFile, baselineAsset:baselineAsset, screenInputs:screenInputs, compileTasks:compileTasks] }
220
+ }
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@orbital_mcp/patchrelay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha04",
4
+ "description": "Build and sign patches for prepared PatchRelay Android projects",
5
+ "type": "module",
6
+ "bin": { "patchrelay": "bin/patchrelay.mjs" },
7
+ "engines": { "node": ">=22" },
8
+ "files": ["bin/", "src/", "README.md", "LICENSE", "gradle-plugin/build.gradle", "gradle-plugin/settings.gradle", "gradle-plugin/src/main/"],
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "prepack": "node scripts/package.mjs"
12
+ },
13
+ "license": "SEE LICENSE IN LICENSE",
14
+ "publishConfig": { "access": "public", "tag": "alpha" }
15
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,279 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, renameSync } from 'node:fs';
2
+ import { join, resolve, dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { sha256, readJson, hostHash, inside, fingerprint, checkNative, emptyOutput, command, jdkTool, gradle, loadBaseline, signedManifest, routeId, reserveVersion, verifiedEnvelope, updateCatalog, signPayload, validateUrl, baselineFiles } from './project.mjs';
7
+
8
+ const configName = 'patchrelay.config.json';
9
+ const help = `PatchRelay — build and sign patches for prepared Android projects
10
+
11
+ patchrelay init Configure this project once
12
+ patchrelay baseline Build and save a named base release
13
+ patchrelay patch Select a baseline, build and sign a patch
14
+ patchrelay catalog Update a signed app/channel catalog locally
15
+ patchrelay baseline --from REF Recover an explicitly selected old release
16
+
17
+ Options: --name NAME, --baseline PATH, --version NUMBER, --url HTTPS_URL,
18
+ --tag TAG, --out PATH, --from REF, --apk PATH
19
+ Catalog options: --patch PATH, --catalog PATH_OR_new, --channel NAME, --url MANIFEST_URL
20
+
21
+ No automatic baseline selection. No uploads. No Python required.
22
+ Requires Node 22+, JDK 17, Android SDK and a prepared PatchRelay screen module.`;
23
+
24
+ function argumentsFor(args) {
25
+ const [action = 'help', ...rest] = args;
26
+ const options = {};
27
+ const allowed = { init: [], baseline: ['name', 'out', 'from', 'apk'], patch: ['baseline', 'version', 'url', 'tag', 'out'],
28
+ catalog: ['patch', 'catalog', 'channel', 'url', 'out'] };
29
+ if (['help', '--help', '-h', '--version'].includes(action)) return { action, options };
30
+ if (!allowed[action]) throw new Error(`Unknown command: ${action}. Run patchrelay --help.`);
31
+ if (rest.includes('--help')) return { action: 'help', options };
32
+ for (let i = 0; i < rest.length; i += 2) {
33
+ const key = rest[i].replace(/^--/, '');
34
+ if (!rest[i].startsWith('--') || !allowed[action].includes(key) || !rest[i + 1] || rest[i + 1].startsWith('--') || Object.hasOwn(options, key)) {
35
+ throw new Error(`Invalid option: ${rest[i]}. Run patchrelay --help.`);
36
+ }
37
+ options[key] = rest[i + 1];
38
+ }
39
+ if (options.apk && !options.from) throw new Error('--apk requires an explicit --from revision.');
40
+ return { action, options };
41
+ }
42
+
43
+ function findRoot(start) {
44
+ let path = resolve(start);
45
+ while (true) {
46
+ if (existsSync(join(path, configName)) || existsSync(join(path, 'runtime.json'))) return path;
47
+ const parent = dirname(path);
48
+ if (parent === path) throw new Error('Run in a prepared Android project containing runtime.json. See the CLI integration guide.');
49
+ path = parent;
50
+ }
51
+ }
52
+
53
+ function name(value) {
54
+ 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.');
55
+ return value;
56
+ }
57
+
58
+ function project(root, fallback) {
59
+ const settings = existsSync(join(root, configName)) ? readJson(join(root, configName)) : fallback;
60
+ if (!settings || settings.format !== 1) throw new Error('Run patchrelay init first.');
61
+ const runtime = readJson(inside(root, settings.runtimeFile));
62
+ 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.');
63
+ return { root, settings, runtime };
64
+ }
65
+
66
+ function nativeFiles(ctx) { return fingerprint(ctx.root, ctx.runtime.nativeInputs, ctx.runtime.textExtensions); }
67
+ function screenFiles(ctx) { return fingerprint(ctx.root, ctx.settings.screenInputs, ctx.runtime.textExtensions); }
68
+ function saveJson(file, data) { writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { flag: 'wx' }); }
69
+
70
+ export function verifyBuild(ctx, expected) {
71
+ const source = readFileSync(inside(ctx.root, ctx.settings.buildConfigFile), 'utf8');
72
+ for (const [field, value] of Object.entries({ HOST_SHA256: expected, APPLICATION_ID: ctx.runtime.appId, BASE_VERSION: ctx.runtime.baseVersion })) {
73
+ let text = source;
74
+ if (field === 'HOST_SHA256') {
75
+ const generated = source.match(/\bHOST_SHA256 = ([\w.]+\.PatchRelayBuild)\.HOST_SHA256;/);
76
+ if (generated) {
77
+ const [module, tail] = ctx.settings.buildConfigFile.replaceAll('\\', '/').split('/build/generated/source/buildConfig/');
78
+ const variant = tail?.split('/')[0];
79
+ if (!variant) throw new Error('Configure the standard generated BuildConfig path for Gradle baseline integration.');
80
+ text = readFileSync(inside(ctx.root, `${module}/build/generated/patchrelay/${variant}/${generated[1].replaceAll('.', '/')}.java`), 'utf8');
81
+ }
82
+ }
83
+ const match = text.match(new RegExp(`\\b${field} = ("(?:[^"\\\\]|\\\\.)*")`));
84
+ if (!match || JSON.parse(match[1]) !== value) throw new Error(`Built APK ${field} does not match the release contract. Check host Gradle integration.`);
85
+ }
86
+ }
87
+
88
+ function buildBaseline(ctx, out) {
89
+ const { root, settings, runtime } = ctx;
90
+ emptyOutput(out);
91
+ gradle(root, settings.compileTasks);
92
+ const program = readFileSync(inside(root, settings.programFile));
93
+ if (!program.length || program.length > 2 * 1024 * 1024) throw new Error('Compiled screen program must be 1 byte–2 MiB.');
94
+ new TextDecoder('utf-8', { fatal: true }).decode(program);
95
+ // The bundled program is a native-build input; include it before hashing/building.
96
+ copyFileSync(inside(root, settings.programFile), inside(root, settings.baselineAsset));
97
+ const before = nativeFiles(ctx);
98
+ const sources = screenFiles(ctx);
99
+ gradle(root, settings.baseTasks);
100
+ checkNative(before, nativeFiles(ctx));
101
+ checkNative(sources, screenFiles(ctx));
102
+ verifyBuild(ctx, hostHash(before));
103
+ const apk = readFileSync(inside(root, settings.apkFile));
104
+ writeFileSync(join(out, 'screen-base.apk'), apk, { flag: 'wx' });
105
+ writeFileSync(join(out, 'baseline-screen.js'), program, { flag: 'wx' });
106
+ const staging = inside(root, `.patchrelay/work/${randomUUID()}/sources`);
107
+ mkdirSync(staging, { recursive: true });
108
+ for (const path of [...new Set([...Object.keys(before), ...Object.keys(sources)])]) {
109
+ if (path.split('/').some(part => ['.keys', 'local.properties', '.env'].includes(part)) || /\.(key|pem|p12|jks|keystore)$/i.test(path) ||
110
+ 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.');
111
+ const target = inside(staging, path);
112
+ mkdirSync(dirname(target), { recursive: true });
113
+ copyFileSync(inside(root, path), target);
114
+ }
115
+ checkNative(before, fingerprint(staging, Object.keys(before), runtime.textExtensions));
116
+ checkNative(sources, fingerprint(staging, Object.keys(sources), runtime.textExtensions));
117
+ const archive = join(out, 'baseline-sources.zip');
118
+ command(root, jdkTool('jar'), ['--create', '--file', archive, '-C', staging, '.']);
119
+ const baseline = { format: 3, appId: runtime.appId, baseVersion: runtime.baseVersion, screenApi: runtime.screenApi,
120
+ hostSha256: hostHash(before), screenIds: runtime.screenIds, nativeFiles: before, screenFiles: sources,
121
+ apkSha256: sha256(apk), programSha256: sha256(program), sourcesSha256: sha256(readFileSync(archive)) };
122
+ saveJson(join(out, 'baseline.json'), baseline);
123
+ return baseline;
124
+ }
125
+
126
+ async function init(root, ask) {
127
+ if (existsSync(join(root, configName))) throw new Error('Project is already configured. Edit patchrelay.config.json to change settings.');
128
+ const runtime = readJson(join(root, 'runtime.json'));
129
+ const app = await ask('Android app module', existsSync(join(root, 'demo')) ? 'demo' : 'app');
130
+ const screens = await ask('Prepared Kotlin screen module', 'screens');
131
+ 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.');
132
+ const repository = await ask('Patch GitHub repository (owner/repo), or "https" for another host');
133
+ if (repository !== 'https' && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error('Use owner/repo, or https.');
134
+ const signingKey = await ask('Private signing-key file location (not key contents)', '.keys/patch-signing.key');
135
+ const catalogUrl = validateUrl(await ask('Stable HTTPS URL for this app\'s signed catalog'));
136
+ const channel = await ask('Update channel', 'production');
137
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(channel)) throw new Error('Use a lowercase channel name.');
138
+ const settings = { format: 1, runtimeFile: 'runtime.json', repository: repository === 'https' ? null : repository, signingKey,
139
+ publicKeyFile: `${app}/patch-public-key.txt`,
140
+ screenInputs: [`${screens}/src/commonMain`], compileTasks: [`:${screens}:jvmTest`, `:${screens}:compileProductionExecutableKotlinJs`],
141
+ baseTasks: [`:${app}:assembleRelease`], programFile: `${screens}/build/compileSync/js/main/productionExecutable/kotlin/${screens === 'screens' ? 'PatchRelayScreens' : screens}.js`,
142
+ baselineAsset: `${app}/src/main/assets/baseline-screen.js`, apkFile: `${app}/build/outputs/apk/release/${app}-release.apk`,
143
+ buildConfigFile: `${app}/build/generated/source/buildConfig/release/${runtime.appId.replaceAll('.', '/')}/BuildConfig.java`,
144
+ outputDir: '.patchrelay/releases' };
145
+ const pluginSource = existsSync(fileURLToPath(new URL('../gradle-plugin/build.gradle', import.meta.url)))
146
+ ? fileURLToPath(new URL('../gradle-plugin/', import.meta.url))
147
+ : fileURLToPath(new URL('../../gradle-plugin/', import.meta.url));
148
+ const pluginClass = 'src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy';
149
+ const pluginFolder = existsSync(join(root, 'gradle-plugin', pluginClass)) ? 'gradle-plugin' : 'patchrelay-gradle-plugin';
150
+ if (existsSync(join(root, pluginFolder)) && !existsSync(join(root, pluginFolder, pluginClass))) {
151
+ throw new Error('Plugin destination already contains unrelated files. Preserve them and choose a clean integration directory.');
152
+ }
153
+ if (!existsSync(join(root, pluginFolder))) cpSync(pluginSource, join(root, pluginFolder), {
154
+ recursive: true, errorOnExist: true, force: false,
155
+ filter: path => !path.endsWith('/build') && !path.endsWith('\\build') && !path.endsWith('/.gradle') && !path.endsWith('\\.gradle')
156
+ });
157
+ if (Array.isArray(runtime.nativeInputs)) {
158
+ for (const path of [`${pluginFolder}/src/main`, `${pluginFolder}/build.gradle`, `${pluginFolder}/settings.gradle`]) {
159
+ if (!runtime.nativeInputs.includes(path)) runtime.nativeInputs.push(path);
160
+ }
161
+ writeFileSync(join(root, 'runtime.json'), JSON.stringify(runtime, null, 2) + '\n');
162
+ }
163
+ saveJson(join(root, configName), settings);
164
+ const ignoreFile = join(root, '.gitignore');
165
+ let ignored = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : '';
166
+ for (const rule of ['.patchrelay/', '.keys/', 'local.properties']) {
167
+ if (!ignored.split(/\r?\n/).includes(rule)) ignored += `${ignored.endsWith('\n') || !ignored ? '' : '\n'}${rule}\n`;
168
+ }
169
+ writeFileSync(ignoreFile, ignored);
170
+ const localFile = join(root, 'local.properties');
171
+ const local = existsSync(localFile) ? readFileSync(localFile, 'utf8') : '';
172
+ writeFileSync(localFile, local + (local.endsWith('\n') || !local ? '' : '\n') + `patchrelay.catalogUrl=${catalogUrl}\npatchrelay.channel=${channel}\n`);
173
+ console.log('Saved patchrelay.config.json. Review module paths before building. Keep private keys and .patchrelay/ out of Git.');
174
+ console.log(`Gradle 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.`);
175
+ }
176
+
177
+ async function recover(ctx, ref, original, out) {
178
+ if (!ref || ref.startsWith('-')) throw new Error('Choose an explicit commit, tag or branch.');
179
+ const expected = sha256(readFileSync(original));
180
+ const commit = command(ctx.root, 'git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], true);
181
+ const work = inside(ctx.root, `.patchrelay/recovery/${randomUUID()}`);
182
+ mkdirSync(work, { recursive: true });
183
+ const checkout = join(work, 'checkout');
184
+ command(ctx.root, 'git', ['worktree', 'add', '--detach', checkout, commit]);
185
+ console.log(`Recovery checkout: ${checkout}`);
186
+ const candidate = join(work, 'candidate');
187
+ const baseline = buildBaseline(project(checkout, ctx.settings), candidate);
188
+ if (baseline.apkSha256 !== expected) {
189
+ const file = join(candidate, 'baseline.json');
190
+ // Failed candidates must not look like accepted release records.
191
+ renameSync(file, join(candidate, 'unverified-baseline.json'));
192
+ throw new Error('Rebuilt APK differs from the supplied original APK. Recovery rejected; current checkout is unchanged.');
193
+ }
194
+ baseline.recoveredFromCommit = commit;
195
+ writeFileSync(join(candidate, 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n');
196
+ emptyOutput(out);
197
+ cpSync(candidate, out, { recursive: true, force: false, errorOnExist: true });
198
+ }
199
+
200
+ export async function main(args = process.argv.slice(2), cwd = process.cwd(), input) {
201
+ const { action, options } = argumentsFor(args);
202
+ if (action === '--version') { console.log('0.1.0-alpha04'); return; }
203
+ if (['help', '--help', '-h'].includes(action)) { console.log(help); return; }
204
+ let terminal;
205
+ const ask = input || (async (question, fallback) => {
206
+ if (!process.stdin.isTTY) throw new Error(`Missing value: ${question}. Supply flags or run in an interactive terminal.`);
207
+ terminal ??= createInterface({ input: process.stdin, output: process.stdout });
208
+ const answer = (await terminal.question(`${question}${fallback ? ` [${fallback}]` : ''}: `)).trim() || fallback;
209
+ if (!answer) throw new Error(`${question} is required.`);
210
+ return answer;
211
+ });
212
+ try {
213
+ const root = findRoot(cwd);
214
+ if (action === 'init') { await init(root, ask); return; }
215
+ const ctx = project(root);
216
+ if (action === 'baseline') {
217
+ const release = name(options.name || await ask('New baseline record name'));
218
+ const out = options.out ? resolve(root, options.out) : inside(root, `${ctx.settings.outputDir}/baselines/${release}`);
219
+ if (existsSync(out)) throw new Error('Output exists. Choose a new baseline name or output directory.');
220
+ if (options.from) {
221
+ const apk = resolve(root, options.apk || await ask('Original released APK file'));
222
+ await recover(ctx, options.from, apk, out);
223
+ } else buildBaseline(ctx, out);
224
+ console.log(`Baseline saved: ${out}\nBack up this entire directory privately.`);
225
+ } else if (action === 'catalog') {
226
+ const publicKey = readFileSync(inside(root, ctx.settings.publicKeyFile));
227
+ const patchFile = resolve(root, options.patch || await ask('Signed patch.json file path'));
228
+ const manifest = verifiedEnvelope(readFileSync(patchFile), publicKey);
229
+ if (manifest.appId !== ctx.runtime.appId) throw new Error('Selected patch belongs to another app.');
230
+ const bytes = readFileSync(join(dirname(patchFile), 'screens.js'));
231
+ if (sha256(bytes) !== manifest.sha256) throw new Error('Patch artifact does not match its signed manifest.');
232
+ const previousFile = options.catalog || await ask('Existing signed catalog path, or "new" for the first catalog');
233
+ const previous = previousFile === 'new' ? null : verifiedEnvelope(readFileSync(resolve(root, previousFile)), publicKey);
234
+ const channel = options.channel || await ask('Channel', 'production');
235
+ const url = validateUrl(options.url || await ask('Final HTTPS URL of this patch.json'));
236
+ const catalog = updateCatalog(previous, manifest, url, channel);
237
+ const envelope = signPayload(catalog, readFileSync(resolve(root, ctx.settings.signingKey)), publicKey);
238
+ const out = options.out ? resolve(root, options.out) : inside(root, `${ctx.settings.outputDir}/catalogs/${randomUUID()}`);
239
+ emptyOutput(out);
240
+ saveJson(join(out, 'catalog.json'), envelope);
241
+ 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.`);
242
+ } else {
243
+ const baselineDir = inside(root, `${ctx.settings.outputDir}/baselines`);
244
+ if (!options.baseline && existsSync(baselineDir)) {
245
+ console.log('Available records (choose explicitly):\n' + baselineFiles(baselineDir).map(file => ` ${file}`).join('\n'));
246
+ }
247
+ const file = resolve(root, options.baseline || await ask('Baseline JSON file path'));
248
+ const baseline = loadBaseline(file);
249
+ if (baseline.appId !== ctx.runtime.appId || baseline.baseVersion !== ctx.runtime.baseVersion || baseline.screenApi !== ctx.runtime.screenApi ||
250
+ JSON.stringify(baseline.screenIds) !== JSON.stringify(ctx.runtime.screenIds)) throw new Error('Selected baseline belongs to a different app/base/screen contract.');
251
+ const currentNative = nativeFiles(ctx);
252
+ checkNative(baseline.nativeFiles, currentNative);
253
+ const currentScreens = screenFiles(ctx);
254
+ const changed = [...new Set([...Object.keys(currentScreens), ...Object.keys(baseline.screenFiles)])].filter(path => currentScreens[path] !== baseline.screenFiles[path]);
255
+ if (!changed.length) throw new Error('No supported screen changes found.');
256
+ console.log('Changed screen inputs:\n' + changed.map(path => ` ${path}`).join('\n'));
257
+ const version = Number(options.version || await ask('Patch version (positive integer)'));
258
+ if (!Number.isSafeInteger(version) || version <= 0) throw new Error('Patch version must be a positive safe integer.');
259
+ const route = routeId(baseline);
260
+ const tag = name(options.tag || `patch-${route.slice(0, 24)}-${version}`);
261
+ const url = options.url || (ctx.settings.repository ? `https://github.com/${ctx.settings.repository}/releases/download/${tag}/screens.js` : await ask('Final HTTPS URL for screens.js'));
262
+ const out = options.out ? resolve(root, options.out) : inside(root, `${ctx.settings.outputDir}/patches/${route}/${version}`);
263
+ emptyOutput(out);
264
+ gradle(root, ctx.settings.compileTasks);
265
+ checkNative(currentNative, nativeFiles(ctx));
266
+ checkNative(currentScreens, screenFiles(ctx));
267
+ const bytes = readFileSync(inside(root, ctx.settings.programFile));
268
+ if (sha256(bytes) === baseline.programSha256) throw new Error('Compiled program is unchanged.');
269
+ if (!ctx.settings.publicKeyFile) throw new Error('Configure publicKeyFile to match the key pinned in your app.');
270
+ const envelope = signedManifest(baseline, version, url, bytes, readFileSync(resolve(root, ctx.settings.signingKey)),
271
+ readFileSync(inside(root, ctx.settings.publicKeyFile)));
272
+ reserveVersion(root, baseline, version, () => {
273
+ writeFileSync(join(out, 'screens.js'), bytes, { flag: 'wx' });
274
+ saveJson(join(out, 'patch.json'), envelope);
275
+ });
276
+ console.log(`Signed patch saved: ${out}\nNothing uploaded. Target release tag: ${tag}`);
277
+ }
278
+ } finally { terminal?.close(); }
279
+ }
@@ -0,0 +1,174 @@
1
+ import { createHash, createPrivateKey, createPublicKey, constants, sign, verify } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, lstatSync, mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { resolve, relative, dirname, isAbsolute, basename, join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
7
+ export const readJson = file => JSON.parse(readFileSync(file, 'utf8'));
8
+ export function baselineFiles(folder) {
9
+ if (!existsSync(folder)) return [];
10
+ return readdirSync(folder, { withFileTypes: true }).flatMap(entry => {
11
+ const path = inside(folder, entry.name);
12
+ if (entry.isSymbolicLink()) return [];
13
+ if (entry.isDirectory()) return baselineFiles(path);
14
+ return entry.isFile() && entry.name === 'baseline.json' ? [path] : [];
15
+ }).sort();
16
+ }
17
+ export const routeId = value => sha256(JSON.stringify([value.appId, value.baseVersion, value.hostSha256]));
18
+ // Match Python's existing sorted, ASCII-escaped JSON contract, including Unicode paths.
19
+ export const hostHash = files => sha256(JSON.stringify(Object.fromEntries(Object.entries(files)
20
+ .sort(([a], [b]) => Buffer.compare(Buffer.from(a), Buffer.from(b)))))
21
+ .replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`));
22
+
23
+ export function inside(root, path) {
24
+ const full = resolve(root, path);
25
+ const child = relative(resolve(root), full);
26
+ if (child === '..' || child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(child)) {
27
+ throw new Error('Project path escapes the project directory.');
28
+ }
29
+ if (existsSync(full) && realpathSync(full) !== full) {
30
+ const actual = relative(realpathSync(root), realpathSync(full));
31
+ if (actual === '..' || actual.startsWith('..\\') || actual.startsWith('../') || isAbsolute(actual)) throw new Error('Project symlink escapes the project directory.');
32
+ }
33
+ return full;
34
+ }
35
+
36
+ export function fingerprint(root, paths, extensions) {
37
+ const result = {};
38
+ function visit(file) {
39
+ inside(root, relative(root, file));
40
+ const info = lstatSync(file);
41
+ if (info.isSymbolicLink()) throw new Error('Declare real source paths, not symbolic links.');
42
+ if (info.isDirectory()) {
43
+ for (const name of readdirSync(file).sort()) visit(join(file, name));
44
+ } else if (info.isFile()) {
45
+ let data = readFileSync(file);
46
+ if (extensions.some(suffix => basename(file).endsWith(suffix))) data = Buffer.from(data.toString('utf8').replaceAll('\r\n', '\n'));
47
+ result[relative(root, file).replaceAll('\\', '/')] = sha256(data);
48
+ }
49
+ }
50
+ for (const path of paths) visit(inside(root, path));
51
+ return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
52
+ }
53
+
54
+ export function checkNative(before, after) {
55
+ const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(name => before[name] !== after[name]);
56
+ if (changed.length) throw new Error(`Native inputs changed; build a new base: ${changed.slice(0, 10).join(', ')}`);
57
+ }
58
+
59
+ export function emptyOutput(path) {
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ mkdirSync(path); // Exclusive: never overwrite an existing release directory.
62
+ }
63
+
64
+ export function command(root, executable, args, capture = false) {
65
+ const result = spawnSync(executable, args, { cwd: root, stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8', shell: false });
66
+ if (result.error) throw new Error(`Cannot run ${executable}: ${result.error.message}`);
67
+ if (result.status !== 0) throw new Error(`${executable} failed (${result.status}).${capture ? ` ${result.stderr?.trim() || ''}` : ''}`);
68
+ return result.stdout?.trim();
69
+ }
70
+
71
+ export function jdkTool(name) {
72
+ const binary = process.platform === 'win32' ? `${name}.exe` : name;
73
+ return process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', binary) : binary;
74
+ }
75
+
76
+ export function gradle(root, tasks) {
77
+ command(root, jdkTool('java'), ['-classpath', inside(root, 'gradle/wrapper/gradle-wrapper.jar'),
78
+ 'org.gradle.wrapper.GradleWrapperMain', ...tasks, '--console=plain', '--max-workers=2']);
79
+ }
80
+
81
+ export function loadBaseline(file) {
82
+ const baseline = readJson(file);
83
+ if (baseline.format !== 3 || !baseline.nativeFiles || !baseline.screenFiles || !Array.isArray(baseline.screenIds) ||
84
+ !baseline.screenIds.length || !/^[a-f0-9]{64}$/.test(baseline.hostSha256) ||
85
+ hostHash(baseline.nativeFiles) !== baseline.hostSha256) throw new Error('Invalid baseline compatibility record.');
86
+ if (baseline.sourcesSha256) {
87
+ const archive = join(dirname(file), 'baseline-sources.zip');
88
+ if (!existsSync(archive) || sha256(readFileSync(archive)) !== baseline.sourcesSha256) throw new Error('Baseline source archive is missing or altered.');
89
+ }
90
+ return baseline;
91
+ }
92
+
93
+ export function validateUrl(value) {
94
+ let url;
95
+ try { url = new URL(value); } catch { throw new Error('Use an absolute HTTPS artifact URL.'); }
96
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw new Error('Use HTTPS without URL credentials or a fragment.');
97
+ return url.href;
98
+ }
99
+
100
+ export function signedManifest(baseline, version, artifactUrl, bytes, keyBytes, publicKeyBytes) {
101
+ if (!Number.isSafeInteger(version) || version <= 0) throw new Error('Patch version must be a positive safe integer.');
102
+ if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new Error('Patch must be between 1 byte and 2 MiB.');
103
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
104
+ return signPayload({ schema: 3, screenApi: baseline.screenApi, runtime: 'kotlin-screen-js',
105
+ hostSha256: baseline.hostSha256, appId: baseline.appId, baseVersion: baseline.baseVersion, version,
106
+ screenIds: baseline.screenIds, artifactUrl: validateUrl(artifactUrl), sha256: sha256(bytes) }, keyBytes, publicKeyBytes);
107
+ }
108
+
109
+ export function signPayload(value, keyBytes, publicKeyBytes) {
110
+ const key = createPrivateKey({ key: Buffer.from(keyBytes.toString().trim(), 'base64'), format: 'der', type: 'pkcs8' });
111
+ if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 3072) throw new Error('Use a PatchRelay RSA signing key of at least 3072 bits.');
112
+ if (publicKeyBytes && !createPublicKey(key).export({ format: 'der', type: 'spki' }).equals(Buffer.from(publicKeyBytes.toString().trim(), 'base64'))) {
113
+ throw new Error('Signing key does not match the configured app public key.');
114
+ }
115
+ const payload = Buffer.from(JSON.stringify(value));
116
+ const envelope = { payload: payload.toString('base64'), signature: sign('sha256', payload, { key, padding: constants.RSA_PKCS1_PADDING }).toString('base64') };
117
+ if (Buffer.byteLength(JSON.stringify(envelope)) > 64 * 1024) throw new Error('Signed metadata exceeds the SDK 64 KiB limit.');
118
+ return envelope;
119
+ }
120
+
121
+ export function verifiedEnvelope(bytes, publicKeyBytes) {
122
+ if (bytes.length > 64 * 1024) throw new Error('Signed metadata exceeds 64 KiB.');
123
+ const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
124
+ if (typeof envelope.payload !== 'string' || typeof envelope.signature !== 'string') throw new Error('Invalid signed envelope.');
125
+ const payload = Buffer.from(envelope.payload, 'base64');
126
+ const key = createPublicKey({ key: Buffer.from(publicKeyBytes.toString().trim(), 'base64'), format: 'der', type: 'spki' });
127
+ if (!verify('sha256', payload, key, Buffer.from(envelope.signature, 'base64'))) throw new Error('Metadata signature is invalid.');
128
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payload));
129
+ }
130
+
131
+ export function updateCatalog(previous, manifest, manifestUrl, channel) {
132
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(channel)) throw new Error('Use a lowercase channel name, e.g. production or beta.');
133
+ if (manifest.schema !== 3 || manifest.screenApi !== 2 || manifest.runtime !== 'kotlin-screen-js' || typeof manifest.appId !== 'string' ||
134
+ !manifest.appId || typeof manifest.baseVersion !== 'string' || !manifest.baseVersion || manifest.baseVersion.length > 128 ||
135
+ !/^[a-f0-9]{64}$/.test(manifest.hostSha256) || !Number.isSafeInteger(manifest.version) || manifest.version <= 0) throw new Error('Invalid patch route.');
136
+ const catalog = previous || { schema: 1, kind: 'patchrelay-catalog', appId: manifest.appId, channel, entries: [] };
137
+ if (catalog.schema !== 1 || catalog.kind !== 'patchrelay-catalog' || catalog.appId !== manifest.appId || catalog.channel !== channel ||
138
+ !Array.isArray(catalog.entries) || catalog.entries.length > 64) throw new Error('Existing catalog belongs to a different app/channel or is invalid.');
139
+ const seen = new Set();
140
+ for (const entry of catalog.entries) {
141
+ const route = JSON.stringify([entry.baseVersion, entry.hostSha256]);
142
+ if (typeof entry.baseVersion !== 'string' || !entry.baseVersion || entry.baseVersion.length > 128 || !/^[a-f0-9]{64}$/.test(entry.hostSha256) ||
143
+ !Number.isSafeInteger(entry.version) || entry.version <= 0 || seen.has(route)) throw new Error('Invalid or duplicate catalog route.');
144
+ validateUrl(entry.manifestUrl);
145
+ seen.add(route);
146
+ }
147
+ const old = catalog.entries.find(entry => entry.baseVersion === manifest.baseVersion && entry.hostSha256 === manifest.hostSha256);
148
+ if (old && manifest.version <= old.version) throw new Error('Catalog patch versions must increase for each exact base.');
149
+ const entries = catalog.entries.filter(entry => entry !== old);
150
+ entries.push({ baseVersion: manifest.baseVersion, hostSha256: manifest.hostSha256, version: manifest.version, manifestUrl: validateUrl(manifestUrl) });
151
+ if (entries.length > 64) throw new Error('Catalog has more than 64 base builds.');
152
+ return { schema: 1, kind: 'patchrelay-catalog', appId: manifest.appId, channel, entries };
153
+ }
154
+
155
+ export function reserveVersion(root, baseline, version, writePatch) {
156
+ const folder = inside(root, '.patchrelay/versions');
157
+ mkdirSync(folder, { recursive: true });
158
+ const file = join(folder, `${routeId(baseline)}.json`);
159
+ let lock;
160
+ try { lock = openSync(file + '.lock', 'wx'); }
161
+ catch { throw new Error('Version record is locked. Another patch build may be running; inspect stale locks before removing them.'); }
162
+ try {
163
+ if (existsSync(file)) {
164
+ const record = readJson(file);
165
+ if (record.format !== 1 || !Number.isSafeInteger(record.highest) || record.highest <= 0) throw new Error('Invalid local patch version record. Restore its backup.');
166
+ if (version <= record.highest) throw new Error(`Patch version must exceed ${record.highest} for this exact base.`);
167
+ }
168
+ const temporary = file + '.pending';
169
+ // Reserve before emitting files: a failed write consumes a version rather than reusing a signature identity.
170
+ writeFileSync(temporary, JSON.stringify({ format: 1, highest: version }) + '\n', { flag: 'wx', flush: true });
171
+ renameSync(temporary, file);
172
+ writePatch();
173
+ } finally { closeSync(lock); unlinkSync(file + '.lock'); }
174
+ }