@nemoobc/opencode-termux 1.19.1 → 1.20.4
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 +21 -0
- package/README.md +275 -39
- package/agents/apk-builder.md +3 -2
- package/agents/autodev.md +422 -32
- package/agents/coder.md +171 -0
- package/agents/fixer.md +2 -1
- package/agents/orchestrator.md +165 -0
- package/agents/termux-coder.md +2 -1
- package/agents/tester.md +3 -2
- package/bin/opencode-termux.js +185 -0
- package/bin/opencode-termux.ts +179 -0
- package/commands/audit.md +6 -0
- package/commands/coder.md +24 -0
- package/commands/orchestrator.md +15 -0
- package/commands/test-all.md +6 -0
- package/config/opencode.json +1 -1
- package/config/plugins/README.md +39 -0
- package/config/plugins/strip-parameter.js +55 -0
- package/install.mjs +121 -74
- package/lib/alpine.mjs +30 -0
- package/lib/alpine.ts +39 -0
- package/lib/integrity.mjs +24 -0
- package/lib/integrity.ts +28 -0
- package/lib/net.mjs +30 -0
- package/lib/net.ts +51 -0
- package/package.json +17 -4
- package/prebuilt/README.md +77 -0
- package/bin/opencode.js +0 -48
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// strip-parameter.js — opencode plugin (global: ~/.config/opencode/plugins/)
|
|
2
|
+
// Membersihkan raw XML tool-call tags literal (mis. <parameter>, </function_calls>,
|
|
3
|
+
// <parameter name="...">) dari output TEKS model SEBELUM dirender TUI, supaya
|
|
4
|
+
// tag mentah tidak bocor ke layar.
|
|
5
|
+
//
|
|
6
|
+
// Mitigasi lokal atas issue opencode #24316 (bug "<parameter>" literal di TUI),
|
|
7
|
+
// sejalan dengan pendekatan PR #27984 (strip dangling XML artifacts).
|
|
8
|
+
//
|
|
9
|
+
// CATATAN: Hook "experimental.text.complete" bersifat eksperimental & hanya
|
|
10
|
+
// memodifikasi output teks. Plugin murni ESM tanpa dependensi eksternal.
|
|
11
|
+
|
|
12
|
+
export const StripParameterPlugin = async (ctx) => {
|
|
13
|
+
try {
|
|
14
|
+
await ctx?.client?.app.log?.({
|
|
15
|
+
body: {
|
|
16
|
+
service: "strip-parameter",
|
|
17
|
+
level: "info",
|
|
18
|
+
message: "Plugin initialized — mensupresi raw XML tool-call tags dari output teks",
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"experimental.text.complete": async (_input, output) => {
|
|
25
|
+
const before = output.text
|
|
26
|
+
if (typeof before !== "string" || !before.includes("<")) return
|
|
27
|
+
|
|
28
|
+
let text = before
|
|
29
|
+
|
|
30
|
+
// 1) Hapus blok tool-call XML lengkap (tag + konten)
|
|
31
|
+
text = text.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "")
|
|
32
|
+
text = text.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "")
|
|
33
|
+
text = text.replace(/<invoke\b[^>]*>[\s\S]*?<\/invoke>/g, "")
|
|
34
|
+
text = text.replace(/<antml:function_calls>[\s\S]*?<\/antml:function_calls>/g, "")
|
|
35
|
+
|
|
36
|
+
// 2) Hapus pasangan <parameter ...>...</parameter> & <parameter>...</parameter>
|
|
37
|
+
text = text.replace(/<parameter\b[^>]*>[\s\S]*?<\/parameter>/g, "")
|
|
38
|
+
|
|
39
|
+
// 3) Hapus tag tool-call "dangling" (pembuka/penutup tanpa pasangan)
|
|
40
|
+
text = text.replace(/<\/?parameter\b[^>]*>/g, "")
|
|
41
|
+
text = text.replace(/<\/?invoke\b[^>]*>/g, "")
|
|
42
|
+
text = text.replace(/<\/?function_calls\b[^>]*>/g, "")
|
|
43
|
+
text = text.replace(/<\/?tool_call\b[^>]*>/g, "")
|
|
44
|
+
text = text.replace(/<\/?antml:\w+(?:\s[^>]*)?>/g, "")
|
|
45
|
+
text = text.replace(/<\|mask_start\|>[\s\S]*?<\|mask_end\|>/g, "")
|
|
46
|
+
|
|
47
|
+
// 4) Rapikan baris kosong berlebih & spasi sisa
|
|
48
|
+
text = text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n")
|
|
49
|
+
|
|
50
|
+
if (text !== before) output.text = text
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default StripParameterPlugin
|
package/install.mjs
CHANGED
|
@@ -11,33 +11,35 @@ import { execFileSync } from "child_process"
|
|
|
11
11
|
import { Readable } from "stream"
|
|
12
12
|
import { pipeline } from "stream/promises"
|
|
13
13
|
import { fileURLToPath } from "url"
|
|
14
|
+
import { alpinePkg } from "./lib/alpine.mjs"
|
|
15
|
+
import { fetchWithRetry } from "./lib/net.mjs"
|
|
16
|
+
import { expectedFromRegistry, verifySha512 } from "./lib/integrity.mjs"
|
|
14
17
|
|
|
15
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
19
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
|
17
20
|
const ARCH = process.env.OCX_ARCH || "arm64"
|
|
18
21
|
const FORCE = !!process.env.OCX_FORCE
|
|
19
22
|
const IS_ANDROID = process.platform === "android"
|
|
23
|
+
const T0 = Date.now()
|
|
24
|
+
const log = m => console.log(`[opencode-termux] ${m}`)
|
|
20
25
|
|
|
21
26
|
// Versi upstream: env > package.json > otomatis ambil terbaru dari registry
|
|
22
27
|
let V = process.env.OCX_UPSTREAM || pkgJson.opencodeUpstream
|
|
23
28
|
if (!V) {
|
|
24
29
|
const latest = await (await fetch("https://registry.npmjs.org/opencode-ai/latest")).json()
|
|
25
30
|
V = latest.version
|
|
26
|
-
|
|
31
|
+
log(`upstream opencode-ai terbaru: ${V}`)
|
|
27
32
|
}
|
|
28
|
-
const GLOBAL_HINT =
|
|
29
|
-
process.platform === "win32" ? "" : ""
|
|
30
|
-
|
|
31
33
|
if (!IS_ANDROID && !FORCE) {
|
|
32
|
-
|
|
34
|
+
log("Bukan Termux/Android — instalasi dilewati (pakai opencode-ai resmi).")
|
|
33
35
|
process.exit(0)
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const A = ARCH === "x64" ? "x86_64" : "aarch64"
|
|
37
39
|
|
|
38
40
|
async function dl(url, dest) {
|
|
39
|
-
|
|
40
|
-
const res = await fetch(
|
|
41
|
+
log(`download ${url.split("/").pop()}`)
|
|
42
|
+
const res = await fetchWithRetry(fetch, url, {}, 3, m => log(m))
|
|
41
43
|
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
|
42
44
|
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(dest))
|
|
43
45
|
}
|
|
@@ -54,87 +56,132 @@ const untar = (tgz, dest, members = []) => {
|
|
|
54
56
|
const work = path.join(__dirname, ".build")
|
|
55
57
|
fs.rmSync(work, { recursive: true, force: true })
|
|
56
58
|
fs.mkdirSync(work, { recursive: true })
|
|
57
|
-
const AV = "v3.21", AL = "3.21.3"
|
|
58
|
-
|
|
59
|
-
// 1) binary opencode (musl) dari npm resmi
|
|
60
|
-
await dl(`https://registry.npmjs.org/opencode-linux-${ARCH}-musl/-/opencode-linux-${ARCH}-musl-${V}.tgz`, `${work}/oc.tgz`)
|
|
61
|
-
untar(`${work}/oc.tgz`, `${work}/oc`)
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
async function fetchLatestAlpineVersion() {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchWithRetry(fetch, "https://dl-cdn.alpinelinux.org/alpine/latest-stable/", {}, 3)
|
|
63
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
64
|
+
const text = await res.text()
|
|
65
|
+
const match = text.match(/href="(v\d+\.\d+)"/)
|
|
66
|
+
if (match) return match[1]
|
|
67
|
+
} catch {}
|
|
68
|
+
return "v3.21"
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const mini = `${work}/ap`; await dl(
|
|
80
|
-
`https://dl-cdn.alpinelinux.org/alpine/${AV}/releases/${A}/alpine-minirootfs-${AL}-${A}.tar.gz`, `${work}/ap.tgz`)
|
|
81
|
-
untar(`${work}/ap.tgz`, mini, ["lib"])
|
|
82
|
-
cp(`${mini}/lib`, `ld-musl-${A}.so.1`); fs.renameSync(path.join(vendor, `ld-musl-${A}.so.1`), path.join(vendor, "ld-musl.so"))
|
|
71
|
+
async function fetchAlpineReleaseVersion(version) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetchWithRetry(fetch, `https://dl-cdn.alpinelinux.org/alpine/${version}/releases/${A}/`, {}, 3)
|
|
74
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
75
|
+
const text = await res.text()
|
|
76
|
+
const match = text.match(new RegExp(`alpine-minirootfs-(\\d+\\.\\d+\\.\\d+)-${A}\\.tar\\.gz`))
|
|
77
|
+
if (match) return match[1]
|
|
78
|
+
} catch {}
|
|
79
|
+
return "3.21.3"
|
|
83
80
|
}
|
|
84
|
-
cp(`${work}/oc/package/bin`, "opencode")
|
|
85
|
-
cp(`${apkDir}/usr/lib`, "libstdc++.so.6"); cp(`${apkDir}/usr/lib`, "libstdc++.so.6.0.33"); cp(`${apkDir}/usr/lib`, "libgcc_s.so.1")
|
|
86
|
-
for (const f of fs.readdirSync(vendor)) fs.chmodSync(path.join(vendor, f), 0o755)
|
|
87
81
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
82
|
+
try {
|
|
83
|
+
const AV = process.env.OCX_ALPINE_VERSION || await fetchLatestAlpineVersion()
|
|
84
|
+
const AL = await fetchAlpineReleaseVersion(AV)
|
|
85
|
+
log(`Alpine version: ${AV} (release ${AL})`)
|
|
86
|
+
|
|
87
|
+
// Resolusi dinamis paket Alpine dari CDN (lihat lib/alpine.mjs)
|
|
88
|
+
const pkg = name => alpinePkg(fetch, `https://dl-cdn.alpinelinux.org/alpine/${AV}/main/${A}`, name)
|
|
89
|
+
|
|
90
|
+
// 1) binary opencode (musl) dari npm resmi — diverifikasi sha512 registry
|
|
91
|
+
const ocTgz = `opencode-linux-${ARCH}-musl-${V}.tgz`
|
|
92
|
+
await dl(`https://registry.npmjs.org/opencode-linux-${ARCH}-musl/-/${ocTgz}`, `${work}/oc.tgz`)
|
|
93
|
+
log("verifikasi integritas sha512…")
|
|
94
|
+
const pk = await (await fetchWithRetry(fetch, `https://registry.npmjs.org/opencode-linux-${ARCH}-musl`, {}, 3)).json()
|
|
95
|
+
verifySha512(`${work}/oc.tgz`, expectedFromRegistry(pk, V))
|
|
96
|
+
untar(`${work}/oc.tgz`, `${work}/oc`)
|
|
97
|
+
|
|
98
|
+
// 2) libgcc + libstdc++ (versi terbaru yang tersedia di CDN)
|
|
99
|
+
const apkDir = `${work}/apk`; fs.mkdirSync(apkDir, { recursive: true })
|
|
100
|
+
for (const name of ["libgcc", "libstdc%2B%2B"]) {
|
|
101
|
+
const f = await pkg(name)
|
|
102
|
+
await dl(`https://dl-cdn.alpinelinux.org/alpine/${AV}/main/${A}/${f}`, `${apkDir}/${f}`)
|
|
103
|
+
untar(`${apkDir}/${f}`, apkDir)
|
|
100
104
|
}
|
|
101
|
-
}
|
|
102
|
-
ensureEtc()
|
|
103
105
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
// 3) rakit vendor/
|
|
107
|
+
const vendor = path.join(__dirname, "vendor")
|
|
108
|
+
fs.rmSync(vendor, { recursive: true, force: true }); fs.mkdirSync(vendor)
|
|
109
|
+
const cp = (dir, name) => fs.copyFileSync(path.join(dir, name), path.join(vendor, name))
|
|
110
|
+
if (A === "aarch64") {
|
|
111
|
+
// loader hasil build khusus: resolv.conf & hosts menunjuk ke prefix Termux
|
|
112
|
+
cp(path.join(__dirname, "prebuilt"), "ld-musl-aarch64-termux.so")
|
|
113
|
+
fs.renameSync(path.join(vendor, "ld-musl-aarch64-termux.so"), path.join(vendor, "ld-musl.so"))
|
|
114
|
+
} else {
|
|
115
|
+
const mini = `${work}/ap`; await dl(
|
|
116
|
+
`https://dl-cdn.alpinelinux.org/alpine/${AV}/releases/${A}/alpine-minirootfs-${AL}-${A}.tar.gz`, `${work}/ap.tgz`)
|
|
117
|
+
untar(`${work}/ap.tgz`, mini, ["lib"])
|
|
118
|
+
cp(`${mini}/lib`, `ld-musl-${A}.so.1`); fs.renameSync(path.join(vendor, `ld-musl-${A}.so.1`), path.join(vendor, "ld-musl.so"))
|
|
119
|
+
}
|
|
120
|
+
cp(`${work}/oc/package/bin`, "opencode")
|
|
121
|
+
cp(`${apkDir}/usr/lib`, "libstdc++.so.6"); cp(`${apkDir}/usr/lib`, "libstdc++.so.6.0.33"); cp(`${apkDir}/usr/lib`, "libgcc_s.so.1")
|
|
122
|
+
for (const f of fs.readdirSync(vendor)) fs.chmodSync(path.join(vendor, f), 0o755)
|
|
110
123
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
// 4) siapkan DNS config di prefix Termux (bisa ditulis TANPA root)
|
|
125
|
+
function ensureEtc() {
|
|
126
|
+
try {
|
|
127
|
+
const PREFIX = process.env.TERMUX_PREFIX || "/data/data/com.termux/files/usr"
|
|
128
|
+
const etc = path.join(PREFIX, "etc")
|
|
129
|
+
fs.mkdirSync(etc, { recursive: true })
|
|
130
|
+
const rc = path.join(etc, "resolv.conf")
|
|
131
|
+
if (!fs.existsSync(rc)) fs.writeFileSync(rc, "nameserver 1.1.1.1\nnameserver 8.8.8.8\n")
|
|
132
|
+
const hh = path.join(etc, "hosts")
|
|
133
|
+
if (!fs.existsSync(hh)) fs.writeFileSync(hh, "127.0.0.1 localhost\n")
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (!FORCE && IS_ANDROID) throw e
|
|
136
|
+
log("peringatan: setup resolv.conf dilewati (" + e.message.split("\n")[0] + ")")
|
|
123
137
|
}
|
|
124
138
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
ensureEtc()
|
|
140
|
+
|
|
141
|
+
// 5) smoke test (LD_PRELOAD termux-exec dibuang: tidak kompatibel dengan musl)
|
|
142
|
+
if (process.env.OCX_SKIP_SMOKE === "1") {
|
|
143
|
+
log("smoke test dilewati (OCX_SKIP_SMOKE=1 — mode cross-build)")
|
|
130
144
|
} else {
|
|
131
|
-
|
|
145
|
+
log("smoke test…")
|
|
146
|
+
const { LD_PRELOAD, LD_PRELOAD_32BIT, ...cleanEnv } = process.env
|
|
147
|
+
execFileSync(path.join(vendor, "ld-musl.so"),
|
|
148
|
+
[path.join(vendor, "opencode"), "--version"],
|
|
149
|
+
{ stdio: "inherit", env: { ...cleanEnv, LD_LIBRARY_PATH: vendor } })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 6) auto-install agents, commands & config opencode (tanpa menimpa milik user)
|
|
153
|
+
try {
|
|
154
|
+
const HOME = process.env.HOME || "/data/data/com.termux/files/home"
|
|
155
|
+
const OC = path.join(HOME, ".config", "opencode")
|
|
156
|
+
for (const [srcDir, dstName] of [["agents", "agent"], ["commands", "command"]]) {
|
|
157
|
+
const src = path.join(__dirname, srcDir)
|
|
158
|
+
if (!fs.existsSync(src)) continue
|
|
159
|
+
const dst = path.join(OC, dstName)
|
|
160
|
+
fs.mkdirSync(dst, { recursive: true })
|
|
161
|
+
for (const f of fs.readdirSync(src)) {
|
|
162
|
+
fs.copyFileSync(path.join(src, f), path.join(dst, f))
|
|
163
|
+
log(`✅ terpasang: ${dstName}/${f}`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const cfgSrc = path.join(__dirname, "config", "opencode.json")
|
|
167
|
+
const cfgDst = path.join(OC, "opencode.json")
|
|
168
|
+
if (!fs.existsSync(cfgDst)) {
|
|
169
|
+
fs.copyFileSync(cfgSrc, cfgDst)
|
|
170
|
+
log("✅ config default terpasang (model gratis)")
|
|
171
|
+
} else {
|
|
172
|
+
log("config user sudah ada — tidak disentuh")
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
log("auto-install agent dilewati:", e.message)
|
|
132
176
|
}
|
|
133
177
|
} catch (e) {
|
|
134
|
-
console.
|
|
178
|
+
console.error("[opencode-termux] ❌ instalasi gagal:", e.message)
|
|
179
|
+
process.exitCode = 1
|
|
180
|
+
throw e
|
|
181
|
+
} finally {
|
|
182
|
+
fs.rmSync(work, { recursive: true, force: true })
|
|
135
183
|
}
|
|
136
184
|
|
|
137
|
-
|
|
138
|
-
console.log(`[opencode-termux] ✅ siap!
|
|
185
|
+
console.log(`[opencode-termux] ✅ siap dalam ${((Date.now() - T0) / 1000).toFixed(1)}s
|
|
139
186
|
• global : jalankan 'opencode-termux'
|
|
140
187
|
• lokal : 'npx opencode-termux' dari folder project ini`)
|
package/lib/alpine.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver paket Alpine dari CDN — anti-404 saat Alpine memperbarui paket
|
|
3
|
+
* dalam satu branch. Fallback ke versi terakhir yang diketahui hidup.
|
|
4
|
+
*/
|
|
5
|
+
export const cmpVer = (a, b) => {
|
|
6
|
+
const pa = a.split(/[.\-r]/).filter(Boolean).map(Number)
|
|
7
|
+
const pb = b.split(/[.\-r]/).filter(Boolean).map(Number)
|
|
8
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
9
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0)
|
|
10
|
+
}
|
|
11
|
+
return 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function alpinePkg(fetchFn, cdnBase, nameEncoded, fallback = "14.2.0-r4") {
|
|
15
|
+
let latest = null
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetchFn(`${cdnBase}/`)
|
|
18
|
+
const idx = await res.text()
|
|
19
|
+
const re = new RegExp(`${nameEncoded}-([0-9][0-9a-zA-Z.+]*)-r([0-9]+)\\.apk`, "g")
|
|
20
|
+
for (const m of idx.matchAll(re)) {
|
|
21
|
+
const v = `${m[1]}-r${m[2]}`
|
|
22
|
+
if (!latest || cmpVer(v, latest.v) > 0) latest = { file: m[0], v }
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
if (!latest) {
|
|
26
|
+
console.warn(`[opencode-termux] listing CDN gagal — fallback ${nameEncoded}-${fallback}.apk`)
|
|
27
|
+
latest = { file: `${nameEncoded}-${fallback}.apk`, v: fallback }
|
|
28
|
+
}
|
|
29
|
+
return latest.file
|
|
30
|
+
}
|
package/lib/alpine.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver paket Alpine dari CDN — anti-404 saat Alpine memperbarui paket
|
|
3
|
+
* dalam satu branch. Fallback ke versi terakhir yang diketahui hidup.
|
|
4
|
+
*/
|
|
5
|
+
export function cmpVer(a: string, b: string): number {
|
|
6
|
+
const pa = a.split(/[.\-r]/).filter(Boolean).map(Number)
|
|
7
|
+
const pb = b.split(/[.\-r]/).filter(Boolean).map(Number)
|
|
8
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
9
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0)
|
|
10
|
+
}
|
|
11
|
+
return 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FetchFn {
|
|
15
|
+
(url: string, options?: RequestInit): Promise<{ text: () => Promise<string> }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function alpinePkg(
|
|
19
|
+
fetchFn: FetchFn,
|
|
20
|
+
cdnBase: string,
|
|
21
|
+
nameEncoded: string,
|
|
22
|
+
fallback = "14.2.0-r4"
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
let latest: { file: string; v: string } | null = null
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetchFn(`${cdnBase}/`)
|
|
27
|
+
const idx = await res.text()
|
|
28
|
+
const re = new RegExp(`${nameEncoded}-([0-9][0-9a-zA-Z.+]*)-r([0-9]+)\\.apk`, "g")
|
|
29
|
+
for (const m of idx.matchAll(re)) {
|
|
30
|
+
const v = `${m[1]}-r${m[2]}`
|
|
31
|
+
if (!latest || cmpVer(v, latest.v) > 0) latest = { file: m[0], v }
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
if (!latest) {
|
|
35
|
+
console.warn(`[opencode-termux] listing CDN gagal — fallback ${nameEncoded}-${fallback}.apk`)
|
|
36
|
+
latest = { file: `${nameEncoded}-${fallback}.apk`, v: fallback }
|
|
37
|
+
}
|
|
38
|
+
return latest.file
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifikasi integritas file terhadap hash sha512 format registry npm
|
|
3
|
+
* ("sha512-<base64>"). Mencegah tarball korup/termanipulasi saat transit.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "crypto"
|
|
6
|
+
import fs from "fs"
|
|
7
|
+
|
|
8
|
+
export const expectedFromRegistry = (packument, version) => {
|
|
9
|
+
const dist = packument?.versions?.[version]?.dist
|
|
10
|
+
return dist?.integrity?.startsWith("sha512-") ? dist.integrity.slice(7) : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function verifySha512(filePath, expectedB64) {
|
|
14
|
+
if (!expectedB64) throw new Error("tidak ada hash acuan (integrity) dari registry")
|
|
15
|
+
const h = crypto.createHash("sha512")
|
|
16
|
+
h.update(fs.readFileSync(filePath))
|
|
17
|
+
const got = h.digest("base64")
|
|
18
|
+
const a = Buffer.from(got)
|
|
19
|
+
const b = Buffer.from(expectedB64)
|
|
20
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
21
|
+
throw new Error(`integritas gagal: file ≠ sha512 registry (${filePath})`)
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}
|
package/lib/integrity.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifikasi integritas file terhadap hash sha512 format registry npm
|
|
3
|
+
* ("sha512-<base64>"). Mencegah tarball korup/termanipulasi saat transit.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "crypto"
|
|
6
|
+
import fs from "fs"
|
|
7
|
+
|
|
8
|
+
export interface Packument {
|
|
9
|
+
versions?: Record<string, { dist?: { integrity?: string } }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function expectedFromRegistry(packument: Packument, version: string): string | null {
|
|
13
|
+
const dist = packument?.versions?.[version]?.dist
|
|
14
|
+
return dist?.integrity?.startsWith("sha512-") ? dist.integrity.slice(7) : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function verifySha512(filePath: string, expectedB64: string): true {
|
|
18
|
+
if (!expectedB64) throw new Error("tidak ada hash acuan (integrity) dari registry")
|
|
19
|
+
const h = crypto.createHash("sha512")
|
|
20
|
+
h.update(fs.readFileSync(filePath))
|
|
21
|
+
const got = h.digest("base64")
|
|
22
|
+
const a = Buffer.from(got)
|
|
23
|
+
const b = Buffer.from(expectedB64)
|
|
24
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
25
|
+
throw new Error(`integritas gagal: file ≠ sha512 registry (${filePath})`)
|
|
26
|
+
}
|
|
27
|
+
return true
|
|
28
|
+
}
|
package/lib/net.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitas jaringan: fetch dengan retry + backoff eksponensial.
|
|
3
|
+
* fetchFn dapat disuntik untuk unit test.
|
|
4
|
+
*/
|
|
5
|
+
const RETRYABLE_ERRORS = new Set(["ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "ENETUNREACH", "EAI_AGAIN"])
|
|
6
|
+
|
|
7
|
+
export async function fetchWithRetry(fetchFn, url, opts = {}, retries = 3, log = () => {}) {
|
|
8
|
+
let lastErr
|
|
9
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetchFn(url, opts)
|
|
12
|
+
// 429/5xx → layak dicoba ulang; 4xx lain → gagal permanen
|
|
13
|
+
if (!res.ok && res.status < 500 && res.status !== 429) return res
|
|
14
|
+
if (res.ok) return res
|
|
15
|
+
lastErr = new Error(`HTTP ${res.status} — ${url}`)
|
|
16
|
+
} catch (e) {
|
|
17
|
+
lastErr = e
|
|
18
|
+
const code = e?.cause?.code || e?.code
|
|
19
|
+
if (code && !RETRYABLE_ERRORS.has(code)) {
|
|
20
|
+
throw e
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (attempt < retries) {
|
|
24
|
+
const wait = Math.min(1000 * 2 ** (attempt - 1), 8000)
|
|
25
|
+
log(`gagal (${attempt}/${retries}) — ulang dalam ${wait}ms`)
|
|
26
|
+
await new Promise(r => setTimeout(r, wait))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw lastErr
|
|
30
|
+
}
|
package/lib/net.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitas jaringan: fetch dengan retry + backoff eksponensial.
|
|
3
|
+
* fetchFn dapat disuntik untuk unit test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface ResponseLike {
|
|
7
|
+
ok: boolean
|
|
8
|
+
status: number
|
|
9
|
+
body: ReadableStream<Uint8Array> | null
|
|
10
|
+
text(): Promise<string>
|
|
11
|
+
json<T = unknown>(): Promise<T>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ErrorWithCode extends Error {
|
|
15
|
+
code?: string
|
|
16
|
+
cause?: { code?: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RETRYABLE_ERRORS = new Set(["ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "ENETUNREACH", "EAI_AGAIN"])
|
|
20
|
+
|
|
21
|
+
export type FetchFn = (url: string, options?: RequestInit) => Promise<ResponseLike | Response>
|
|
22
|
+
|
|
23
|
+
export async function fetchWithRetry(
|
|
24
|
+
fetchFn: FetchFn,
|
|
25
|
+
url: string,
|
|
26
|
+
opts: RequestInit = {},
|
|
27
|
+
retries = 3,
|
|
28
|
+
log: (msg: string) => void = () => {}
|
|
29
|
+
): Promise<ResponseLike | Response> {
|
|
30
|
+
let lastErr: unknown
|
|
31
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetchFn(url, opts)
|
|
34
|
+
if (!res.ok && res.status < 500 && res.status !== 429) return res
|
|
35
|
+
if (res.ok) return res
|
|
36
|
+
lastErr = new Error(`HTTP ${res.status} — ${url}`)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
lastErr = e
|
|
39
|
+
const code = (e as ErrorWithCode).cause?.code || (e as ErrorWithCode).code
|
|
40
|
+
if (code && !RETRYABLE_ERRORS.has(code)) {
|
|
41
|
+
throw e
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (attempt < retries) {
|
|
45
|
+
const wait = Math.min(1000 * 2 ** (attempt - 1), 8000)
|
|
46
|
+
log(`gagal (${attempt}/${retries}) — ulang dalam ${wait}ms`)
|
|
47
|
+
await new Promise(r => setTimeout(r, wait))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw lastErr
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nemoobc/opencode-termux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.4",
|
|
4
4
|
"description": "opencode CLI native untuk Termux/Android tanpa proot — membundel loader musl + binary opencode resmi (upstream opencode-ai)",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"bin": {
|
|
6
|
-
"opencode-termux": "./bin/opencode.js"
|
|
7
|
+
"opencode-termux": "./bin/opencode-termux.js"
|
|
7
8
|
},
|
|
8
9
|
"scripts": {
|
|
9
|
-
"postinstall": "node install.mjs"
|
|
10
|
+
"postinstall": "node install.mjs",
|
|
11
|
+
"test": "node test/run.mjs",
|
|
12
|
+
"test:e2e": "node test/run.mjs --e2e",
|
|
13
|
+
"lint": "echo 'No linter configured'",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"build": "tsc"
|
|
10
16
|
},
|
|
11
17
|
"keywords": [
|
|
12
18
|
"opencode",
|
|
@@ -22,13 +28,20 @@
|
|
|
22
28
|
"files": [
|
|
23
29
|
"bin",
|
|
24
30
|
"install.mjs",
|
|
31
|
+
"lib",
|
|
25
32
|
"prebuilt",
|
|
33
|
+
"LICENSE",
|
|
26
34
|
"README.md",
|
|
27
35
|
"agents",
|
|
28
36
|
"commands",
|
|
29
37
|
"config"
|
|
30
38
|
],
|
|
31
|
-
"
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^26.4.0",
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"tsx": "^4.19.0"
|
|
43
|
+
},
|
|
44
|
+
"opencodeUpstream": "1.18.23",
|
|
32
45
|
"repository": {
|
|
33
46
|
"type": "git",
|
|
34
47
|
"url": "git+https://github.com/nemoobc/opencode-termux.git"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Prebuilt musl Loader Rebuild Guide
|
|
2
|
+
|
|
3
|
+
File `ld-musl-aarch64-termux.so` adalah custom musl loader yang dipatch agar:
|
|
4
|
+
- Membaca `/etc/resolv.conf` dan `/etc/hosts` dari `$PREFIX/etc/` (Termux prefix)
|
|
5
|
+
- Bukan dari `/etc/` (butuh root)
|
|
6
|
+
|
|
7
|
+
## Cara Rebuild
|
|
8
|
+
|
|
9
|
+
### Prasyarat
|
|
10
|
+
- Linux host (bisa WSL, VM, atau CI)
|
|
11
|
+
- Docker terinstall
|
|
12
|
+
- `aarch64-linux-musl` toolchain
|
|
13
|
+
|
|
14
|
+
### Langkah Build
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 1. Clone musl repo (versi yang kompatibel dengan Alpine 3.21)
|
|
18
|
+
git clone https://git.musl-libc.org/git/musl
|
|
19
|
+
cd musl
|
|
20
|
+
git checkout v1.2.5 # atau tag yang dipakai Alpine 3.21
|
|
21
|
+
|
|
22
|
+
# 2. Patch src/internal/dynlink.c untuk ganti path resolv.conf & hosts
|
|
23
|
+
# Cari baris yang define RESOLV_CONF dan HOSTS_PATH, ganti ke:
|
|
24
|
+
# #define RESOLV_CONF "/data/data/com.termux/files/usr/etc/resolv.conf"
|
|
25
|
+
# #define HOSTS_PATH "/data/data/com.termux/files/usr/etc/hosts"
|
|
26
|
+
|
|
27
|
+
# 3. Build dengan cross-compiler aarch64
|
|
28
|
+
./configure --prefix=/out --target=aarch64-linux-musl --disable-shared
|
|
29
|
+
make -j$(nproc)
|
|
30
|
+
make install
|
|
31
|
+
|
|
32
|
+
# 4. Ambil ld-musl-aarch64.so.1 dari /out/lib/
|
|
33
|
+
# Rename ke ld-musl-aarch64-termux.so
|
|
34
|
+
cp /out/lib/ld-musl-aarch64.so.1 ../prebuilt/ld-musl-aarch64-termux.so
|
|
35
|
+
chmod +x ../prebuilt/ld-musl-aarch64-termux.so
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Alternatif: Build via Docker (lebih bersih)
|
|
39
|
+
|
|
40
|
+
```dockerfile
|
|
41
|
+
# Dockerfile.build-musl
|
|
42
|
+
FROM alpine:3.21 AS builder
|
|
43
|
+
RUN apk add --no-cache build-base linux-headers git
|
|
44
|
+
WORKDIR /musl
|
|
45
|
+
RUN git clone https://git.musl-libc.org/git/musl . && git checkout v1.2.5
|
|
46
|
+
# Apply patch di sini (sed atau patch file)
|
|
47
|
+
RUN sed -i 's|/etc/resolv.conf|/data/data/com.termux/files/usr/etc/resolv.conf|g' src/internal/dynlink.c
|
|
48
|
+
RUN sed -i 's|/etc/hosts|/data/data/com.termux/files/usr/etc/hosts|g' src/internal/dynlink.c
|
|
49
|
+
RUN ./configure --prefix=/out --target=aarch64-linux-musl --disable-shared && make -j$(nproc) && make install
|
|
50
|
+
|
|
51
|
+
FROM scratch
|
|
52
|
+
COPY --from=builder /out/lib/ld-musl-aarch64.so.1 /ld-musl-aarch64-termux.so
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
docker build -f Dockerfile.build-musl -t musl-builder .
|
|
57
|
+
docker create --name temp musl-builder
|
|
58
|
+
docker cp temp:/ld-musl-aarch64-termux.so ./prebuilt/ld-musl-aarch64-termux.so
|
|
59
|
+
docker rm temp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Verifikasi
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Cek ELF
|
|
66
|
+
file prebuilt/ld-musl-aarch64-termux.so
|
|
67
|
+
# Output: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked
|
|
68
|
+
|
|
69
|
+
# Cek string patch
|
|
70
|
+
strings prebuilt/ld-musl-aarch64-termux.so | grep -E 'resolv.conf|hosts'
|
|
71
|
+
# Harus muncul path Termux prefix
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Catatan
|
|
75
|
+
- Loader ini **hanya untuk ARM64 (aarch64)**. Untuk x64, install.mjs otomatis ambil dari Alpine minirootfs.
|
|
76
|
+
- Jika Alpine naik versi major (mis. 3.22), mungkin perlu rebuild ulang loader agar kompatibel.
|
|
77
|
+
- Simpan binary hasil build ke `prebuilt/ld-musl-aarch64-termux.so` dan commit.
|