@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 +71 -0
- package/README.md +173 -2
- package/bin/patchrelay.mjs +7 -0
- package/gradle-plugin/build.gradle +15 -0
- package/gradle-plugin/settings.gradle +1 -0
- package/gradle-plugin/src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy +220 -0
- package/package.json +13 -4
- package/src/main.mjs +279 -0
- package/src/project.mjs +174 -0
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
|
-
#
|
|
1
|
+
# PatchRelay CLI
|
|
2
2
|
|
|
3
|
-
|
|
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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -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
|
+
}
|