@orbital_mcp/patchrelay 0.1.0-alpha04 → 0.1.0-alpha05
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.patchrelay-tools/build.gradle +22 -0
- package/.patchrelay-tools/settings.gradle +1 -0
- package/.patchrelay-tools/src/main/groovy/dev/patchrelay/gradle/FunctionsPlugin.groovy +328 -0
- package/{gradle-plugin → .patchrelay-tools}/src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy +226 -220
- package/.patchrelay-tools/src/main/kotlin/dev/patchrelay/compiler/PatchRelayCompilerRegistrar.kt +70 -0
- package/.patchrelay-tools/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
- package/.patchrelay-tools/src/main/resources/patchrelay/ComposeScreens.kt +139 -0
- package/README.md +249 -174
- package/package.json +2 -2
- package/src/main.mjs +511 -279
- package/src/project.mjs +196 -174
- package/gradle-plugin/build.gradle +0 -15
- package/gradle-plugin/settings.gradle +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package dev.patchrelay.compose
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.*
|
|
4
|
+
import androidx.compose.runtime.snapshots.Snapshot
|
|
5
|
+
import kotlinx.serialization.json.*
|
|
6
|
+
import kotlin.coroutines.EmptyCoroutineContext
|
|
7
|
+
|
|
8
|
+
private class RelayNode(val type: String) {
|
|
9
|
+
var id = ""
|
|
10
|
+
var text = ""
|
|
11
|
+
var style = "body"
|
|
12
|
+
var enabled = true
|
|
13
|
+
var value = ""
|
|
14
|
+
var checked = false
|
|
15
|
+
var event: ((String?) -> Unit)? = null
|
|
16
|
+
val children = mutableListOf<RelayNode>()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private class RelayApplier(root: RelayNode) : AbstractApplier<RelayNode>(root) {
|
|
20
|
+
override fun insertTopDown(index: Int, instance: RelayNode) { current.children.add(index, instance) }
|
|
21
|
+
override fun insertBottomUp(index: Int, instance: RelayNode) {}
|
|
22
|
+
override fun remove(index: Int, count: Int) { current.children.remove(index, count) }
|
|
23
|
+
override fun move(from: Int, to: Int, count: Int) { current.children.move(from, to, count) }
|
|
24
|
+
override fun onClear() { root.children.clear() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Explicit render/event transactions: no frame loop, browser DOM or asynchronous effects. */
|
|
28
|
+
@OptIn(ExperimentalComposeApi::class)
|
|
29
|
+
class RelayComposition(private val screenId: String, private val backAction: () -> String? = { null },
|
|
30
|
+
private val content: @Composable () -> Unit) {
|
|
31
|
+
private val root = RelayNode("column")
|
|
32
|
+
private val recomposer = Recomposer(EmptyCoroutineContext)
|
|
33
|
+
private val composition = ControlledComposition(RelayApplier(root), recomposer)
|
|
34
|
+
private val observer = Snapshot.registerApplyObserver { values, _ ->
|
|
35
|
+
if (!composition.isDisposed) composition.recordModificationsOf(values)
|
|
36
|
+
}
|
|
37
|
+
private var started = false
|
|
38
|
+
private var active = false
|
|
39
|
+
private var actions = emptyMap<String, RelayNode>()
|
|
40
|
+
|
|
41
|
+
fun render(): String = transaction { renderTree() }
|
|
42
|
+
|
|
43
|
+
fun event(action: String, value: String? = null): String = transaction {
|
|
44
|
+
require(value == null || value.length <= 1024) { "Compose event value too large" }
|
|
45
|
+
val node = actions[action] ?: error("Unknown Compose action: $action")
|
|
46
|
+
require(node.enabled) { "Disabled Compose action" }
|
|
47
|
+
node.event!!(value)
|
|
48
|
+
renderTree()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private inline fun transaction(body: () -> String): String {
|
|
52
|
+
check(!composition.isDisposed && !active) { "Compose screen disposed or reentrant" }
|
|
53
|
+
active = true
|
|
54
|
+
try { return body() }
|
|
55
|
+
catch (failure: Throwable) { dispose(); throw failure }
|
|
56
|
+
finally { active = false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun renderTree(): String {
|
|
60
|
+
if (!started) { started = true; composition.setContent(content) }
|
|
61
|
+
Snapshot.sendApplyNotifications()
|
|
62
|
+
var passes = 0
|
|
63
|
+
while (composition.hasInvalidations) {
|
|
64
|
+
check(++passes <= 16) { "Compose did not settle within 16 passes" }
|
|
65
|
+
val snapshot = Snapshot.takeMutableSnapshot({ composition.recordReadOf(it) }, { composition.recordWriteOf(it) })
|
|
66
|
+
val changed = try { snapshot.enter { composition.recompose() } }
|
|
67
|
+
finally { try { snapshot.apply().check() } finally { snapshot.dispose() } }
|
|
68
|
+
if (changed) { composition.applyChanges(); composition.applyLateChanges() }
|
|
69
|
+
composition.changesApplied()
|
|
70
|
+
Snapshot.notifyObjectsInitialized()
|
|
71
|
+
Snapshot.sendApplyNotifications()
|
|
72
|
+
}
|
|
73
|
+
val ids = mutableSetOf<String>()
|
|
74
|
+
val nextActions = mutableMapOf<String, RelayNode>()
|
|
75
|
+
fun children(node: RelayNode, depth: Int): JsonArray {
|
|
76
|
+
require(node.children.size in 1..32) { "Compose layout requires 1..32 children" }
|
|
77
|
+
return JsonArray(node.children.map { child ->
|
|
78
|
+
require(depth <= 8 && ids.size < 128) { "Compose screen complexity exceeded" }
|
|
79
|
+
require(child.id.matches(Regex("[a-z][a-z0-9_]{0,23}")) && ids.add(child.id)) { "Invalid or duplicate Compose widget ID: ${child.id}" }
|
|
80
|
+
buildJsonObject {
|
|
81
|
+
put("id", child.id); put("type", child.type)
|
|
82
|
+
if (child.type in setOf("column", "row", "card")) put("children", children(child, depth + 1))
|
|
83
|
+
else {
|
|
84
|
+
require(child.text.isNotBlank() && child.text.length <= 1024) { "Invalid Compose widget text" }
|
|
85
|
+
put("text", child.text)
|
|
86
|
+
if (child.type == "text") {
|
|
87
|
+
require(child.style in setOf("title", "body", "caption", "error"))
|
|
88
|
+
put("style", child.style)
|
|
89
|
+
} else {
|
|
90
|
+
val action = "c_" + child.id
|
|
91
|
+
nextActions[action] = child
|
|
92
|
+
put("action", action); put("enabled", child.enabled)
|
|
93
|
+
if (child.type == "input") { require(child.value.length <= 1024); put("value", child.value) }
|
|
94
|
+
if (child.type == "toggle") put("checked", child.checked)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
val result = buildJsonObject {
|
|
101
|
+
put("screenId", screenId); put("type", "column"); put("children", children(root, 1))
|
|
102
|
+
backAction()?.let { require(it.matches(Regex("[a-z][a-z0-9_-]{0,31}"))); put("backAction", it) }
|
|
103
|
+
}.toString()
|
|
104
|
+
require(result.length <= 65536) { "Compose screen too large" }
|
|
105
|
+
actions = nextActions
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun dispose() {
|
|
110
|
+
observer.dispose()
|
|
111
|
+
composition.dispose()
|
|
112
|
+
recomposer.cancel()
|
|
113
|
+
actions = emptyMap()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Composable
|
|
118
|
+
private fun Node(type: String, id: String, text: String = "", style: String = "body", enabled: Boolean = true,
|
|
119
|
+
value: String = "", checked: Boolean = false, event: ((String?) -> Unit)? = null,
|
|
120
|
+
content: @Composable () -> Unit = {}) {
|
|
121
|
+
ComposeNode<RelayNode, RelayApplier>(factory = { RelayNode(type) }, update = {
|
|
122
|
+
set(id) { this.id = it }; set(text) { this.text = it }; set(style) { this.style = it }
|
|
123
|
+
set(enabled) { this.enabled = it }; set(value) { this.value = it }; set(checked) { this.checked = it }
|
|
124
|
+
set(event) { this.event = it }
|
|
125
|
+
}, content = content)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Composable fun Text(id: String, text: String, style: String = "body") = Node("text", id, text, style)
|
|
129
|
+
@Composable fun Button(id: String, text: String, enabled: Boolean = true, onClick: () -> Unit) =
|
|
130
|
+
Node("button", id, text, enabled = enabled, event = { onClick() })
|
|
131
|
+
@Composable fun TextField(id: String, label: String, value: String, enabled: Boolean = true, onValueChange: (String) -> Unit) =
|
|
132
|
+
Node("input", id, label, enabled = enabled, value = value, event = { onValueChange(it ?: error("Missing input value")) })
|
|
133
|
+
@Composable fun Toggle(id: String, label: String, checked: Boolean, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit) =
|
|
134
|
+
Node("toggle", id, label, enabled = enabled, checked = checked, event = {
|
|
135
|
+
require(it == "true" || it == "false") { "Invalid toggle value" }; onCheckedChange(it == "true")
|
|
136
|
+
})
|
|
137
|
+
@Composable fun Column(id: String, content: @Composable () -> Unit) = Node("column", id, content = content)
|
|
138
|
+
@Composable fun Row(id: String, content: @Composable () -> Unit) = Node("row", id, content = content)
|
|
139
|
+
@Composable fun Card(id: String, content: @Composable () -> Unit) = Node("card", id, content = content)
|
package/README.md
CHANGED
|
@@ -1,174 +1,249 @@
|
|
|
1
|
-
# PatchRelay CLI
|
|
2
|
-
|
|
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
|
|
7
|
-
separate Maven dependency; installing it does not install this CLI.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
patchrelay
|
|
39
|
-
patchrelay
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
the
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
the
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
# PatchRelay CLI
|
|
2
|
+
|
|
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`](https://www.npmjs.com/package/@orbital_mcp/patchrelay) (CLI `0.1.0-alpha05`). The Android SDK is a
|
|
7
|
+
separate Maven dependency; installing it does not install this CLI. See the
|
|
8
|
+
[public quickstart](https://patchrelay.vercel.app/) and [hosting guide](https://patchrelay.vercel.app/hosting).
|
|
9
|
+
|
|
10
|
+
## Install in a project
|
|
11
|
+
|
|
12
|
+
Requirements: Node.js 22+, JDK 17+, Android SDK 35, and the Android project's
|
|
13
|
+
Gradle wrapper. From the prepared Android project root:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install --save-dev @orbital_mcp/patchrelay
|
|
17
|
+
npx patchrelay --help
|
|
18
|
+
npx patchrelay init
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`init` creates project config/build tooling and prints the Gradle plugin changes
|
|
22
|
+
to apply. Add the Android SDK separately from Maven Central. Build and install a
|
|
23
|
+
release APK with its stable catalog URL before publishing patches.
|
|
24
|
+
|
|
25
|
+
## Run current source
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
node .\cli\bin\patchrelay.mjs --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use this checkout's CLI for generated function contracts. Existing alpha installations predate these changes. A local package can be built with `npm pack ./cli`; no publication is needed. Signing keys are not package inputs. See [generated Kotlin integration](../docs/kotlin-workflow.md).
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
Run inside your prepared Android project:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
patchrelay init
|
|
39
|
+
patchrelay baseline
|
|
40
|
+
# Edit patchable screen Kotlin.
|
|
41
|
+
patchrelay compare --baseline PATH_TO_BASELINE_JSON
|
|
42
|
+
patchrelay compare --baseline PATH_TO_BASELINE_JSON --diff
|
|
43
|
+
patchrelay patch
|
|
44
|
+
patchrelay catalog
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`init` asks for module names, GitHub repository or HTTPS patch-host base URL,
|
|
48
|
+
signing-key location, stable signed-catalog HTTPS URL, channel and an existing
|
|
49
|
+
local catalog (or `new` for the first release). It records generated JS/APK/
|
|
50
|
+
BuildConfig/key paths, the release APK baseline pointer, and a patch output folder
|
|
51
|
+
under `<screens-module>/build/patchrelay`. Kotlin sources are discovered recursively,
|
|
52
|
+
not entered one file at a time. It writes
|
|
53
|
+
`patchrelay.config.json` without key contents and appends catalog URL/channel
|
|
54
|
+
settings to `local.properties`, preserving existing SDK path settings.
|
|
55
|
+
It also copies the Gradle build plugin and prints the one-time settings/app plugin
|
|
56
|
+
hooks. Also apply `dev.patchrelay.functions` to the JVM/JS shared module and add that module as an Android dependency. Apply those hooks so normal `assembleRelease` and `bundleRelease` builds
|
|
57
|
+
automatically preserve matching baseline records; no extra baseline command is
|
|
58
|
+
then required. This repository's demo already has the hooks. The plugin remains
|
|
59
|
+
limited to prepared screen modules, not arbitrary Kotlin screen rewriting.
|
|
60
|
+
It adds `.patchrelay/` and `.keys/` to Git ignore rules without removing existing
|
|
61
|
+
rules. Custom signing-key locations still need their own ignore protection.
|
|
62
|
+
It does not generate/rotate keys, install SDK hooks, migrate ordinary native
|
|
63
|
+
screens or configure private-host authentication. Review generated JS/APK/
|
|
64
|
+
BuildConfig paths before building. A custom Kotlin/JS export name or Gradle
|
|
65
|
+
variant needs corresponding paths at setup (or edits to `patchrelay.config.json`).
|
|
66
|
+
|
|
67
|
+
### Which files go where?
|
|
68
|
+
|
|
69
|
+
| File | Purpose | Upload to patch hosting? |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| Kotlin source files | Developer writes screens, functions and navigation | No |
|
|
72
|
+
| `patchrelay.config.json`, `.patchrelay-tools/` | Setup creates project settings/build tools; commit with app source | No |
|
|
73
|
+
| Generated hooks, function contract, compiled build output | Build creates integration and program | No direct upload |
|
|
74
|
+
| Baseline directory (APK, JSON, bundled program, source ZIP) | Immutable private release backup | No |
|
|
75
|
+
| Private signing key | Developer-owned signing credential | Never |
|
|
76
|
+
| `screens.js` in the patch output directory | Complete compiled patch program | Yes, first |
|
|
77
|
+
| `patch.json` beside that program | Signed compatibility manifest | Yes, second |
|
|
78
|
+
| `catalog.json` from the catalog command | Signed discovery catalog | Yes, last, at stable catalog URL |
|
|
79
|
+
|
|
80
|
+
Upload the **patch output** `screens.js`, not a similarly named build file.
|
|
81
|
+
Output names are fixed; choose project input paths during setup and output folders
|
|
82
|
+
with `--out`. `patchrelay catalog` prints the exact three local paths and hosting
|
|
83
|
+
destinations. `patch` does not upload automatically; `patch --github` is the
|
|
84
|
+
GitHub Releases option. Retain older hosted patches while catalog routes
|
|
85
|
+
reference them. Public keys belong in the native app; private
|
|
86
|
+
keys and baseline archives stay in private storage.
|
|
87
|
+
|
|
88
|
+
`baseline` asks for a unique record name, builds/tests the configured screen
|
|
89
|
+
module, bundles its program, builds the APK, checks the native fingerprint and
|
|
90
|
+
saves APK, program, compatibility JSON and private source ZIP under
|
|
91
|
+
`.patchrelay/releases/baselines/NAME`. It changes the configured bundled JS asset
|
|
92
|
+
as part of preparing that native build. Back up the entire baseline directory
|
|
93
|
+
privately. Local output is not cloud backup. Do not publish the source ZIP.
|
|
94
|
+
|
|
95
|
+
`compare --baseline PATH` compares **all files recursively** under configured
|
|
96
|
+
native/screen inputs against that explicitly selected release. It lists every
|
|
97
|
+
`ADDED`, `MODIFIED` and `REMOVED` file, grouped by native/build inputs versus
|
|
98
|
+
supported screen inputs, with counts. There is no one-file or 100-file limit;
|
|
99
|
+
the test suite checks a 150-file change set. Missing declared inputs are reported.
|
|
100
|
+
Comparison follows baseline content hashes, including configured text line-ending
|
|
101
|
+
normalization, rather than the Git working tree or the last patch.
|
|
102
|
+
|
|
103
|
+
Add `--diff` to see actual added/deleted source lines for every changed file,
|
|
104
|
+
using the baseline's verified private source ZIP. It requires Git plus the JDK
|
|
105
|
+
`jar` tool, extracts sources into an isolated temporary directory and cleans it
|
|
106
|
+
afterward. Binary files get Git's binary-change notice. A normal comparison
|
|
107
|
+
performs no build, signing, version reservation or project writes. No application
|
|
108
|
+
source is changed by either mode. Files outside declared inputs are not included;
|
|
109
|
+
this is a released-app comparison, not a whole-repository or semantic-code audit.
|
|
110
|
+
Native changes require a new APK. Matching native hashes do not prove correctness:
|
|
111
|
+
function/signature compatibility and configured tests run during `patch`.
|
|
112
|
+
|
|
113
|
+
After setup and the first release APK build, the Gradle hook records an
|
|
114
|
+
`apk-record.json` pointer to the current baseline. `patch` uses that pointer,
|
|
115
|
+
reserves the next version, and writes `screens.js`, `patch.json` and an updated
|
|
116
|
+
`catalog.json` directly under `<screens-module>/build/patchrelay/patches`.
|
|
117
|
+
On a successful patch build, it replaces the previous generated patch folder;
|
|
118
|
+
the stable working catalog remains at its separate configured path.
|
|
119
|
+
For a non-GitHub host, setup stores the HTTPS base URL; patch files are
|
|
120
|
+
versioned under the generated release tag. The current signed catalog is kept at
|
|
121
|
+
the configured local catalog path so later patches preserve earlier base routes.
|
|
122
|
+
The command prints the three exact files and their hosting destinations. For a
|
|
123
|
+
personal server, upload `screens.js` and `patch.json` into the tag-named folder
|
|
124
|
+
under `patchBaseUrl`, then replace `catalog.json` at `catalogUrl` last. The APK
|
|
125
|
+
must be configured with the stable `catalogUrl`, never `patchBaseUrl`. See
|
|
126
|
+
[`docs/update-routing.md`](../docs/update-routing.md) for a concrete server layout
|
|
127
|
+
with multiple patch folders and the first-install checklist. Each run uses a new
|
|
128
|
+
tag folder; keep prior folders unchanged while the catalog references them. The
|
|
129
|
+
catalog URL stays the same and its signed contents are replaced after each upload.
|
|
130
|
+
Explicit `--baseline`, `--version`, `--url` and
|
|
131
|
+
`--out` still override the defaults. Native/contract changes require a new APK
|
|
132
|
+
baseline. Output directories are exclusive; prior releases are never overwritten.
|
|
133
|
+
|
|
134
|
+
GitHub artifact URLs use the configured repository and the convention
|
|
135
|
+
`patch-ROUTE_HASH-VERSION`; the route hash binds app ID, base version and exact
|
|
136
|
+
native fingerprint, avoiding collisions between apps/builds. `--tag` overrides
|
|
137
|
+
that release tag. Run `patch --github` to create a GitHub Release containing
|
|
138
|
+
`screens.js`, `patch.json` and the updated `catalog.json`. It uses the `origin`
|
|
139
|
+
GitHub repository and current branch, and publishes the catalog at the stable
|
|
140
|
+
`releases/latest/download/catalog.json` URL. GitHub credentials must be available
|
|
141
|
+
through Git Credential Manager or `GITHUB_TOKEN`. The command saves this catalog
|
|
142
|
+
URL in ignored `local.properties` for the next APK build. Install an APK built
|
|
143
|
+
with this URL before expecting it to discover release patches. For another HTTPS
|
|
144
|
+
host, the command prints exact upload paths and destinations. See
|
|
145
|
+
[`docs/update-routing.md`](../docs/update-routing.md) for how the app resolves
|
|
146
|
+
catalog → manifest → program and how to configure personal hosting.
|
|
147
|
+
|
|
148
|
+
Patch outputs from the configured one-command flow are under
|
|
149
|
+
`<screens-module>/build/patchrelay/patches`. `screens.js` is the complete
|
|
150
|
+
compiled program for the current supported screens, so it contains all current
|
|
151
|
+
screen changes together. Version numbers still increase for each exact APK base;
|
|
152
|
+
the local output folder is cleaned and reused, while the signed catalog records
|
|
153
|
+
the active manifest route.
|
|
154
|
+
A locked, local version record under `.patchrelay/versions` prevents reusing or
|
|
155
|
+
lowering a version for the same exact base even with a different output folder.
|
|
156
|
+
A failed write after reservation consumes that version; choose a higher one.
|
|
157
|
+
Back up these records. They cannot detect versions published from another
|
|
158
|
+
machine unless you retain the latest published records/catalog. All channels
|
|
159
|
+
for an exact base share the version sequence.
|
|
160
|
+
|
|
161
|
+
## Stable update discovery
|
|
162
|
+
|
|
163
|
+
`patchrelay catalog` remains available for manual workflows. It asks for a signed
|
|
164
|
+
patch manifest, the existing signed catalog (or explicit `new` for the first
|
|
165
|
+
one), channel and final manifest HTTPS URL.
|
|
166
|
+
It verifies signatures/artifact hashes, preserves routes for older bases and
|
|
167
|
+
updates only that patch's exact app/base route. It rejects a route downgrade,
|
|
168
|
+
wrong app/channel, ambiguous routes and more than 64 supported base builds.
|
|
169
|
+
It writes a new signed `catalog.json` locally, never overwriting hosted data.
|
|
170
|
+
|
|
171
|
+
Use one stable catalog URL per app/channel. Different apps can share one GitHub
|
|
172
|
+
repository while using separate catalogs, signing keys and immutable patch
|
|
173
|
+
releases. Upload/verify `screens.js` and its `patch.json` first, then replace
|
|
174
|
+
the hosted catalog. Keep every artifact still referenced by an older base.
|
|
175
|
+
GitHub asset replacement is not atomic; a brief failed check must leave the
|
|
176
|
+
current app working. Private-source hosting still needs accessible HTTPS patch
|
|
177
|
+
delivery or a separate authenticated gateway; GitHub credentials aren't embedded.
|
|
178
|
+
|
|
179
|
+
The prepared host calls `updates.stageCatalog(catalogUrl, channel)` off the UI
|
|
180
|
+
thread. The SDK verifies the catalog, selects the exact installed fingerprint,
|
|
181
|
+
then verifies the selected manifest and downloads only the latest complete
|
|
182
|
+
program. No match means no update. Existing version/signature checks still apply.
|
|
183
|
+
This supports skipping intermediate patches, not arbitrary data migrations.
|
|
184
|
+
The new demo checks once per process after healthy first rendering, silently;
|
|
185
|
+
the downloaded patch activates on a later process start, not mid-session.
|
|
186
|
+
|
|
187
|
+
Automation can supply explicit values:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
patchrelay baseline --name android-v1
|
|
191
|
+
patchrelay patch --baseline .patchrelay/releases/baselines/android-v1/baseline.json --version 1
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
There is no default signing key material and no default publisher repository.
|
|
195
|
+
The configured `publicKeyFile` must contain the Base64 DER public key pinned in
|
|
196
|
+
the app; signing refuses a mismatched private key. The key file format is the existing PatchRelay Base64 DER PKCS#8 RSA private key
|
|
197
|
+
(minimum 3072 bits). Keep it out of Git and releases. The CLI signs locally;
|
|
198
|
+
private keys are never sent to Maven, npm or the patch host.
|
|
199
|
+
|
|
200
|
+
Normal Gradle baselines are stored at
|
|
201
|
+
`.patchrelay/releases/baselines/APP_ID/VARIANT/apk-ARTIFACT_HASH/` or
|
|
202
|
+
`aab-ARTIFACT_HASH/`. Select the desired `baseline.json` explicitly in `patch`.
|
|
203
|
+
Both formats work for patch compilation; AAB files themselves are not installed
|
|
204
|
+
by this tool. `patchrelay baseline` remains useful for a developer-named APK record.
|
|
205
|
+
|
|
206
|
+
## Recover a lost record
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
patchrelay baseline --from YOUR-COMMIT
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
You supply the record name and original APK when asked. Recovery checks out only
|
|
213
|
+
that explicitly supplied revision into a detached worktree, using its project
|
|
214
|
+
settings when present or the current module-layout settings otherwise. It uses
|
|
215
|
+
the selected revision's runtime contract and sources. Current branch/edits remain
|
|
216
|
+
untouched. The rebuilt APK must match the original APK hash exactly. Different
|
|
217
|
+
signing/toolchain output is rejected; without a matching release, ship a new base.
|
|
218
|
+
Recovery worktrees and build diagnostics stay under `.patchrelay/recovery/`.
|
|
219
|
+
|
|
220
|
+
## Project contract
|
|
221
|
+
|
|
222
|
+
This CLI is independent of this repository's Python tools, but it is **not an
|
|
223
|
+
automatic integration plugin for arbitrary Android apps**. Projects still need:
|
|
224
|
+
|
|
225
|
+
- Prepared Kotlin/JS screens and explicit native hooks.
|
|
226
|
+
- `runtime.json` format 1 / screen API 2 with app/base/screen identity, declared
|
|
227
|
+
native inputs and text-normalization extensions.
|
|
228
|
+
- Gradle generation of `HOST_SHA256`, `BASE_VERSION` and `APPLICATION_ID` in the
|
|
229
|
+
configured BuildConfig, following the existing sorted SHA-256 contract.
|
|
230
|
+
- An Android host using the SDK and configured trust keys/update endpoint.
|
|
231
|
+
|
|
232
|
+
The CLI does not scan ordinary Kotlin to discover patchable methods. Multiple
|
|
233
|
+
app/variant projects need separately reviewed configuration and release records.
|
|
234
|
+
Baseline selection remains manual. Signed catalog authoring/routing is implemented;
|
|
235
|
+
automatic remote uploads and arbitrary app integration are still pending.
|
|
236
|
+
|
|
237
|
+
## Build this CLI from source
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
cd cli
|
|
241
|
+
npm test
|
|
242
|
+
npm pack
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The npm tarball contains CLI runtime files, the Gradle build-plugin source,
|
|
246
|
+
documentation and license: no Android SDK/app implementation, signing keys,
|
|
247
|
+
baseline archives or private R8 mappings.
|
|
248
|
+
The CLI is JavaScript distributed to developers, so its runtime is inspectable.
|
|
249
|
+
Android SDK AAR obfuscation is a separate build step, not protection for CLI code.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orbital_mcp/patchrelay",
|
|
3
|
-
"version": "0.1.0-
|
|
3
|
+
"version": "0.1.0-alpha05",
|
|
4
4
|
"description": "Build and sign patches for prepared PatchRelay Android projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "patchrelay": "bin/patchrelay.mjs" },
|
|
7
7
|
"engines": { "node": ">=22" },
|
|
8
|
-
"files": ["bin/", "src/", "README.md", "LICENSE", "
|
|
8
|
+
"files": ["bin/", "src/", "README.md", "LICENSE", ".patchrelay-tools/build.gradle", ".patchrelay-tools/settings.gradle", ".patchrelay-tools/src/main/"],
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test test/*.test.mjs",
|
|
11
11
|
"prepack": "node scripts/package.mjs"
|