@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.
@@ -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
- console.log(`[opencode-termux] upstream opencode-ai terbaru: ${V}`)
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
- console.log("[opencode-termux] Bukan Termux/Android — instalasi dilewati (pakai opencode-ai resmi).")
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
- console.log(`[opencode-termux] download ${url.split("/").pop()}`)
40
- const res = await fetch(url)
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
- // 2) libgcc + libstdc++
64
- const apkDir = `${work}/apk`; fs.mkdirSync(apkDir, { recursive: true })
65
- for (const f of [`libgcc-14.2.0-r4.apk`, `libstdc%2B%2B-14.2.0-r4.apk`]) {
66
- await dl(`https://dl-cdn.alpinelinux.org/alpine/${AV}/main/${A}/${f}`, `${apkDir}/${f}`)
67
- untar(`${apkDir}/${f}`, apkDir)
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
- // 3) rakit vendor/
71
- const vendor = path.join(__dirname, "vendor")
72
- fs.rmSync(vendor, { recursive: true, force: true }); fs.mkdirSync(vendor)
73
- const cp = (dir, name) => fs.copyFileSync(path.join(dir, name), path.join(vendor, name))
74
- if (A === "aarch64") {
75
- // loader hasil build khusus: resolv.conf & hosts menunjuk ke prefix Termux
76
- cp(path.join(__dirname, "prebuilt"), "ld-musl-aarch64-termux.so")
77
- fs.renameSync(path.join(vendor, "ld-musl-aarch64-termux.so"), path.join(vendor, "ld-musl.so"))
78
- } else {
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
- // 4) siapkan DNS config di prefix Termux (bisa ditulis TANPA root)
89
- function ensureEtc() {
90
- try {
91
- const PREFIX = process.env.TERMUX_PREFIX || "/data/data/com.termux/files/usr"
92
- const etc = path.join(PREFIX, "etc")
93
- fs.mkdirSync(etc, { recursive: true })
94
- const rc = path.join(etc, "resolv.conf")
95
- if (!fs.existsSync(rc)) fs.writeFileSync(rc, "nameserver 1.1.1.1\nnameserver 8.8.8.8\n")
96
- const hh = path.join(etc, "hosts")
97
- if (!fs.existsSync(hh)) fs.writeFileSync(hh, "127.0.0.1 localhost\n")
98
- } catch (e) {
99
- if (!FORCE && IS_ANDROID) throw e
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
- // 5) smoke test (LD_PRELOAD termux-exec dibuang: tidak kompatibel dengan musl)
105
- console.log("[opencode-termux] smoke test…")
106
- const { LD_PRELOAD, LD_PRELOAD_32BIT, ...cleanEnv } = process.env
107
- execFileSync(path.join(vendor, "ld-musl.so"),
108
- [path.join(vendor, "opencode"), "--version"],
109
- { stdio: "inherit", env: { ...cleanEnv, LD_LIBRARY_PATH: vendor } })
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
- // 6) auto-install agents, commands & config opencode (tanpa menimpa milik user)
112
- try {
113
- const HOME = process.env.HOME || "/data/data/com.termux/files/home"
114
- const OC = path.join(HOME, ".config", "opencode")
115
- for (const [srcDir, dstName] of [["agents", "agent"], ["commands", "command"]]) {
116
- const src = path.join(__dirname, srcDir)
117
- if (!fs.existsSync(src)) continue
118
- const dst = path.join(OC, dstName)
119
- fs.mkdirSync(dst, { recursive: true })
120
- for (const f of fs.readdirSync(src)) {
121
- fs.copyFileSync(path.join(src, f), path.join(dst, f))
122
- console.log(`[opencode-termux] terpasang: ${dstName}/${f}`)
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
- const cfgSrc = path.join(__dirname, "config", "opencode.json")
126
- const cfgDst = path.join(OC, "opencode.json")
127
- if (!fs.existsSync(cfgDst)) {
128
- fs.copyFileSync(cfgSrc, cfgDst)
129
- console.log("[opencode-termux] config default terpasang (model gratis)")
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
- console.log("[opencode-termux] config user sudah ada — tidak disentuh")
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.log("[opencode-termux] auto-install agent dilewati:", e.message)
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
- fs.rmSync(work, { recursive: true, force: true })
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
+ }
@@ -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.19.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
- "opencodeUpstream": "1.18.21",
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.