@rubytech/create-maxy-lite 0.1.0 → 0.1.2
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/index.mjs +92 -7
- package/lib/orchestrate.mjs +70 -26
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import fs from 'node:fs'
|
|
20
20
|
import path from 'node:path'
|
|
21
|
-
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
22
22
|
import { fileURLToPath } from 'node:url'
|
|
23
23
|
|
|
24
24
|
import { makeLog } from './lib/log.mjs'
|
|
@@ -34,15 +34,44 @@ const PREFIX = process.env.PREFIX || '/data/data/com.termux/files/usr'
|
|
|
34
34
|
|
|
35
35
|
// ---- subprocess primitives --------------------------------------------------
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// stderr is kept (not just stdout) so a failed command's cause is recoverable
|
|
38
|
+
// from the install log without re-running by hand.
|
|
39
|
+
const decode = (r) => ({ code: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || '' })
|
|
38
40
|
|
|
39
|
-
/** Run a command in bare Termux. */
|
|
41
|
+
/** Run a command in bare Termux (captured, for guards and quick steps). */
|
|
40
42
|
const run = (cmd) => decode(spawnSync('bash', ['-lc', cmd], { encoding: 'utf8' }))
|
|
41
43
|
|
|
42
|
-
/** Run a command inside the glibc Ubuntu layer. */
|
|
44
|
+
/** Run a command inside the glibc Ubuntu layer (captured). */
|
|
43
45
|
const runIn = (cmd) =>
|
|
44
46
|
decode(spawnSync('proot-distro', ['login', PATHS.distro, '--', 'bash', '-lc', cmd], { encoding: 'utf8' }))
|
|
45
47
|
|
|
48
|
+
// Streaming variants for the long, network-bound steps: tee the child's stdout
|
|
49
|
+
// and stderr to the terminal as they arrive (so the operator sees apt/proot/npm
|
|
50
|
+
// progress live), while capturing a bounded slice of stderr for the failure
|
|
51
|
+
// message. Returns the same { code, stdout, stderr } shape, stdout left empty
|
|
52
|
+
// (these commands are run for effect, never parsed).
|
|
53
|
+
const STDERR_CAP = 8192
|
|
54
|
+
|
|
55
|
+
function spawnStreaming(file, args) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
58
|
+
let stderr = ''
|
|
59
|
+
child.stdout.on('data', (d) => process.stdout.write(d))
|
|
60
|
+
child.stderr.on('data', (d) => {
|
|
61
|
+
process.stderr.write(d)
|
|
62
|
+
if (stderr.length < STDERR_CAP) stderr += d.toString()
|
|
63
|
+
})
|
|
64
|
+
child.on('error', (e) => resolve({ code: 1, stdout: '', stderr: String(e && e.message ? e.message : e) }))
|
|
65
|
+
child.on('close', (code) => resolve({ code: code === null ? 1 : code, stdout: '', stderr: stderr.slice(0, STDERR_CAP) }))
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stream a command in bare Termux. */
|
|
70
|
+
const runStream = (cmd) => spawnStreaming('bash', ['-lc', cmd])
|
|
71
|
+
|
|
72
|
+
/** Stream a command inside the glibc Ubuntu layer. */
|
|
73
|
+
const runInStream = (cmd) => spawnStreaming('proot-distro', ['login', PATHS.distro, '--', 'bash', '-lc', cmd])
|
|
74
|
+
|
|
46
75
|
// ---- filesystem primitives --------------------------------------------------
|
|
47
76
|
// The Ubuntu rootfs is a normal directory under the Termux prefix, so the app
|
|
48
77
|
// payload is copied into it directly from bionic Node (no proot round-trip).
|
|
@@ -53,6 +82,25 @@ const appHostPath = `${rootfs}${PATHS.appDir}` // appDir is absolute inside the
|
|
|
53
82
|
const existsHost = (p) => fs.existsSync(p)
|
|
54
83
|
const mkdirHost = (p) => fs.mkdirSync(p, { recursive: true })
|
|
55
84
|
|
|
85
|
+
// ---- shared-storage permission ----------------------------------------------
|
|
86
|
+
// The vault lives on Android shared storage, whose parent (`/storage/emulated/0`)
|
|
87
|
+
// is unwritable until Termux holds the shared-storage permission. `termux-setup-
|
|
88
|
+
// storage` pops the Android grant dialog; the parent becomes writable once the
|
|
89
|
+
// user taps Allow. Detection is the writability of that parent, not an existence
|
|
90
|
+
// check — the directory can be present but inaccessible before the grant.
|
|
91
|
+
|
|
92
|
+
const storageParent = path.dirname(PATHS.vaultHost)
|
|
93
|
+
const storageAccessible = () => {
|
|
94
|
+
try {
|
|
95
|
+
fs.accessSync(storageParent, fs.constants.W_OK)
|
|
96
|
+
return true
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const interactive = Boolean(process.stdin.isTTY)
|
|
102
|
+
const STORAGE_POLL_MS = 3000
|
|
103
|
+
|
|
56
104
|
/** Copy the bundled app payload into the Ubuntu rootfs (idempotent overwrite). */
|
|
57
105
|
const layPayload = () => {
|
|
58
106
|
fs.mkdirSync(appHostPath, { recursive: true })
|
|
@@ -60,9 +108,13 @@ const layPayload = () => {
|
|
|
60
108
|
}
|
|
61
109
|
|
|
62
110
|
/** Install the app deps (validator's js-yaml) and the webchat deps (node-pty, ws). */
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
// Streamed: the webchat deps build node-pty natively, which is slow and verbose;
|
|
112
|
+
// a failure carries the child's stderr so the cause is in the log.
|
|
113
|
+
const installDeps = async () => {
|
|
114
|
+
const app = await runInStream(`cd ${PATHS.appDir} && npm install --omit=dev`)
|
|
115
|
+
if (app.code !== 0) throw new Error(`app deps install failed: ${app.stderr.replace(/\s+/g, ' ').trim()}`)
|
|
116
|
+
const wc = await runInStream(`cd ${WEBCHAT_DIR} && npm install --omit=dev`)
|
|
117
|
+
if (wc.code !== 0) throw new Error(`webchat deps install failed: ${wc.stderr.replace(/\s+/g, ' ').trim()}`)
|
|
66
118
|
}
|
|
67
119
|
|
|
68
120
|
/** Write the `maxy-lite` launcher into the Termux bin dir. */
|
|
@@ -118,17 +170,49 @@ function printDryRun(log) {
|
|
|
118
170
|
|
|
119
171
|
// ---- entry ------------------------------------------------------------------
|
|
120
172
|
|
|
173
|
+
// A long step emits a liveness heartbeat at least this often, so no silent gap
|
|
174
|
+
// between log lines reads as a hang.
|
|
175
|
+
const HEARTBEAT_MS = 10000
|
|
176
|
+
|
|
121
177
|
async function main() {
|
|
122
178
|
const log = makeLog()
|
|
123
179
|
if (process.argv.includes('--dry-run')) {
|
|
124
180
|
printDryRun(log)
|
|
125
181
|
return 0
|
|
126
182
|
}
|
|
183
|
+
// Periodic liveness for a running step: emit `op=step-progress` every
|
|
184
|
+
// HEARTBEAT_MS until stopped. unref so a stray timer never holds the process open.
|
|
185
|
+
const heartbeat = (name, startMs) => {
|
|
186
|
+
const timer = setInterval(() => log('step-progress', { name, elapsedMs: Date.now() - startMs }), HEARTBEAT_MS)
|
|
187
|
+
if (timer.unref) timer.unref()
|
|
188
|
+
return () => clearInterval(timer)
|
|
189
|
+
}
|
|
190
|
+
// Drive the Android shared-storage grant: surface the dialog, then poll the vault
|
|
191
|
+
// parent's writability until the user taps Allow. No timeout — the step heartbeat
|
|
192
|
+
// keeps the wait visibly alive — so a slow grant never fails the install. Returns
|
|
193
|
+
// only once storage is writable; the caller re-runs the vault-bind mutation after.
|
|
194
|
+
const ensureStorageGrant = async () => {
|
|
195
|
+
log('storage-grant', { state: 'requesting' })
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`\n[maxy-lite] Shared storage is needed for the vault at ${PATHS.vaultHost}.\n` +
|
|
198
|
+
`[maxy-lite] An Android dialog will appear — tap "Allow", then leave this running.\n`,
|
|
199
|
+
)
|
|
200
|
+
run('termux-setup-storage')
|
|
201
|
+
while (!storageAccessible()) {
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, STORAGE_POLL_MS))
|
|
203
|
+
}
|
|
204
|
+
log('storage-grant', { state: 'granted' })
|
|
205
|
+
}
|
|
127
206
|
const ctx = {
|
|
128
207
|
run,
|
|
129
208
|
runIn,
|
|
209
|
+
runStream,
|
|
210
|
+
runInStream,
|
|
130
211
|
existsHost,
|
|
131
212
|
mkdirHost,
|
|
213
|
+
storageAccessible,
|
|
214
|
+
interactive,
|
|
215
|
+
ensureStorageGrant,
|
|
132
216
|
layPayload,
|
|
133
217
|
installDeps,
|
|
134
218
|
writeLauncher,
|
|
@@ -137,6 +221,7 @@ async function main() {
|
|
|
137
221
|
pins: PINS,
|
|
138
222
|
now: () => Date.now(),
|
|
139
223
|
log,
|
|
224
|
+
heartbeat,
|
|
140
225
|
}
|
|
141
226
|
const result = await orchestrate(ctx)
|
|
142
227
|
return result.ok ? 0 : 1
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -19,16 +19,44 @@ import { PATHS, WEBCHAT_DIR, launcherPath } from './paths.mjs'
|
|
|
19
19
|
|
|
20
20
|
export const STEP_NAMES = ['termux-deps', 'proot', 'ubuntu', 'node', 'toolchain', 'vault-bind', 'npm-app']
|
|
21
21
|
|
|
22
|
-
/** Throw unless the command result is a clean exit. */
|
|
22
|
+
/** Throw unless the command result is a clean exit; the message carries the command's stderr. */
|
|
23
23
|
function ensureOk(r) {
|
|
24
|
-
if (!r || r.code !== 0)
|
|
24
|
+
if (!r || r.code !== 0) {
|
|
25
|
+
const stderr = r && r.stderr ? String(r.stderr).replace(/\s+/g, ' ').trim() : ''
|
|
26
|
+
const detail = stderr ? `: ${stderr}` : ''
|
|
27
|
+
throw new Error(`command failed (code ${r ? r.code : 'none'})${detail}`)
|
|
28
|
+
}
|
|
25
29
|
return r
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
/** The
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
/** The actionable error when the vault's shared-storage parent is not writable. */
|
|
33
|
+
const STORAGE_PERMISSION_ERROR = 'storage-permission-required: run termux-setup-storage and grant access'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Gate the vault-bind mutation on shared-storage access. The vault parent
|
|
37
|
+
* (`/storage/emulated/0`) is unwritable until Termux has been granted Android
|
|
38
|
+
* shared-storage permission, so `c.mkdirHost(vaultHost)` would otherwise fail with
|
|
39
|
+
* an opaque `Permission denied`. When access is missing we either drive the
|
|
40
|
+
* interactive grant (TTY present), which blocks until the permission lands, or
|
|
41
|
+
* fail fast with a clear, actionable message when no TTY can surface the dialog.
|
|
42
|
+
*/
|
|
43
|
+
async function ensureStorageAccessible(c) {
|
|
44
|
+
if (c.storageAccessible()) return
|
|
45
|
+
if (!c.interactive) throw new Error(STORAGE_PERMISSION_ERROR)
|
|
46
|
+
await c.ensureStorageGrant()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when proot-distro can enter the distro — its own proof the rootfs is
|
|
51
|
+
* installed. A login that exits 0 means proot-distro resolved and entered the
|
|
52
|
+
* rootfs. The earlier guard reconstructed the rootfs path and `fs.existsSync`'d
|
|
53
|
+
* it, which disagreed with proot-distro on-device (present to proot-distro,
|
|
54
|
+
* absent to the path guess) and re-ran `install` into an "already exists"
|
|
55
|
+
* failure. Delegating to proot-distro's own exit code makes the guard agree
|
|
56
|
+
* with the install action by construction, so an installed distro skips.
|
|
57
|
+
*/
|
|
58
|
+
function distroInstalled(c) {
|
|
59
|
+
return c.runIn('true').code === 0
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
/** True when the glibc Node inside Ubuntu matches the pinned major. */
|
|
@@ -43,7 +71,8 @@ const STEPS = [
|
|
|
43
71
|
{
|
|
44
72
|
name: 'termux-deps',
|
|
45
73
|
done: (c) => c.run('command -v proot-distro').code === 0,
|
|
46
|
-
|
|
74
|
+
// Long and network-bound (repo sync + download); stream so the operator sees apt progress.
|
|
75
|
+
run: async (c) => ensureOk(await c.runStream('pkg install -y proot-distro')),
|
|
47
76
|
},
|
|
48
77
|
{
|
|
49
78
|
name: 'proot',
|
|
@@ -52,27 +81,28 @@ const STEPS = [
|
|
|
52
81
|
},
|
|
53
82
|
{
|
|
54
83
|
name: 'ubuntu',
|
|
55
|
-
done: (c) => c
|
|
56
|
-
run: (c) => ensureOk(c.
|
|
84
|
+
done: (c) => distroInstalled(c),
|
|
85
|
+
run: async (c) => ensureOk(await c.runStream(`proot-distro install ${PATHS.distro}`)),
|
|
57
86
|
},
|
|
58
87
|
{
|
|
59
88
|
name: 'node',
|
|
60
89
|
done: (c) => nodeMajorOk(c),
|
|
61
|
-
run: (c) => {
|
|
62
|
-
//
|
|
63
|
-
ensureOk(c.
|
|
64
|
-
ensureOk(c.
|
|
90
|
+
run: async (c) => {
|
|
91
|
+
// runInStream already wraps in `bash -lc`; pass the pipeline directly (no inner bash).
|
|
92
|
+
ensureOk(await c.runInStream(`curl -fsSL https://deb.nodesource.com/setup_${c.pins.nodeMajor}.x | bash -`))
|
|
93
|
+
ensureOk(await c.runInStream('apt install -y nodejs'))
|
|
65
94
|
},
|
|
66
95
|
},
|
|
67
96
|
{
|
|
68
97
|
name: 'toolchain',
|
|
69
98
|
done: (c) => c.runIn('command -v g++').code === 0,
|
|
70
|
-
run: (c) => ensureOk(c.
|
|
99
|
+
run: async (c) => ensureOk(await c.runInStream('apt install -y python3 make g++')),
|
|
71
100
|
},
|
|
72
101
|
{
|
|
73
102
|
name: 'vault-bind',
|
|
74
103
|
done: (c) => c.existsHost(PATHS.vaultHost),
|
|
75
|
-
run: (c) => {
|
|
104
|
+
run: async (c) => {
|
|
105
|
+
await ensureStorageAccessible(c)
|
|
76
106
|
c.mkdirHost(PATHS.vaultHost)
|
|
77
107
|
ensureOk(c.run(`proot-distro login ${PATHS.distro} --bind ${PATHS.vaultHost}:${PATHS.vaultGuest} -- test -d ${PATHS.vaultGuest}`))
|
|
78
108
|
},
|
|
@@ -86,39 +116,53 @@ const STEPS = [
|
|
|
86
116
|
c.runIn(`test -d ${WEBCHAT_DIR}/node_modules`).code === 0 &&
|
|
87
117
|
c.runIn('command -v claude').code === 0 &&
|
|
88
118
|
c.existsHost(launcherPath()),
|
|
89
|
-
run: (c) => {
|
|
90
|
-
ensureOk(c.
|
|
119
|
+
run: async (c) => {
|
|
120
|
+
ensureOk(await c.runInStream(`npm install -g @anthropic-ai/claude-code@${c.pins.claudeCode}`))
|
|
91
121
|
// Pin ttyd to the manifest version via its release binary; apt would float.
|
|
92
122
|
ensureOk(
|
|
93
|
-
c.
|
|
123
|
+
await c.runInStream(
|
|
94
124
|
`curl -fsSL -o /usr/local/bin/ttyd https://github.com/tsl0922/ttyd/releases/download/${c.pins.ttyd}/ttyd.aarch64 && chmod +x /usr/local/bin/ttyd`,
|
|
95
125
|
),
|
|
96
126
|
)
|
|
97
127
|
c.layPayload()
|
|
98
|
-
c.installDeps()
|
|
128
|
+
await c.installDeps()
|
|
99
129
|
c.writeLauncher()
|
|
100
130
|
},
|
|
101
131
|
},
|
|
102
132
|
]
|
|
103
133
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Run one step: announce, guard, then action — timed, one structured line. Never
|
|
136
|
+
* throws. Emits `op=step-start` before any work so the operator always sees what
|
|
137
|
+
* is running; a failed step's `op=step` line carries `err=<message>` (the command's
|
|
138
|
+
* stderr, via ensureOk) so the cause is diagnosable from the log alone. While the
|
|
139
|
+
* action runs, a heartbeat (injected) keeps a long step from looking hung.
|
|
140
|
+
*/
|
|
141
|
+
async function runStep(step, ctx) {
|
|
106
142
|
const start = ctx.now()
|
|
143
|
+
ctx.log('step-start', { name: step.name })
|
|
107
144
|
let ok = false
|
|
108
145
|
let skipped = false
|
|
146
|
+
let errMsg = null
|
|
147
|
+
let stopHeartbeat = null
|
|
109
148
|
try {
|
|
110
149
|
if (step.done(ctx)) {
|
|
111
150
|
skipped = true
|
|
112
151
|
ok = true
|
|
113
152
|
} else {
|
|
114
|
-
|
|
153
|
+
if (ctx.heartbeat) stopHeartbeat = ctx.heartbeat(step.name, start)
|
|
154
|
+
await step.run(ctx)
|
|
115
155
|
ok = true
|
|
116
156
|
}
|
|
117
|
-
} catch {
|
|
118
|
-
|
|
157
|
+
} catch (err) {
|
|
158
|
+
errMsg = err && err.message ? err.message : String(err)
|
|
159
|
+
} finally {
|
|
160
|
+
if (stopHeartbeat) stopHeartbeat()
|
|
119
161
|
}
|
|
120
162
|
const ms = ctx.now() - start
|
|
121
|
-
|
|
163
|
+
const fields = { name: step.name, ok, ms }
|
|
164
|
+
if (!ok && errMsg) fields.err = errMsg.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
165
|
+
ctx.log('step', fields)
|
|
122
166
|
return { name: step.name, ok, skipped, ms }
|
|
123
167
|
}
|
|
124
168
|
|
|
@@ -131,7 +175,7 @@ export async function orchestrate(ctx) {
|
|
|
131
175
|
const t0 = ctx.now()
|
|
132
176
|
const results = []
|
|
133
177
|
for (const step of STEPS) {
|
|
134
|
-
const r = runStep(step, ctx)
|
|
178
|
+
const r = await runStep(step, ctx)
|
|
135
179
|
results.push(r)
|
|
136
180
|
if (!r.ok) {
|
|
137
181
|
ctx.log('done', { ok: false, ranMs: ctx.now() - t0 })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubytech/create-maxy-lite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Install maxy-lite on an Android phone: orchestrates proot-distro Ubuntu, glibc Node, claude, the web-chat relay, the vault and its bind-mount — run via npx in bare Termux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|