@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 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
- const decode = (r) => ({ code: r.status === null ? 1 : r.status, stdout: r.stdout || '' })
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
- const installDeps = () => {
64
- if (runIn(`cd ${PATHS.appDir} && npm install --omit=dev`).code !== 0) throw new Error('app deps install failed')
65
- if (runIn(`cd ${WEBCHAT_DIR} && npm install --omit=dev`).code !== 0) throw new Error('webchat deps install failed')
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
@@ -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) throw new Error(`command failed (code ${r ? r.code : 'none'})`)
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 Ubuntu rootfs path under the Termux prefix. */
29
- function rootfsPath() {
30
- const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr'
31
- return `${prefix}/var/lib/proot-distro/installed-rootfs/${PATHS.distro}`
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
- run: (c) => ensureOk(c.run('pkg install -y proot-distro')),
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.existsHost(rootfsPath()),
56
- run: (c) => ensureOk(c.run(`proot-distro install ${PATHS.distro}`)),
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
- // runIn already wraps in `bash -lc`; pass the pipeline directly (no inner bash).
63
- ensureOk(c.runIn(`curl -fsSL https://deb.nodesource.com/setup_${c.pins.nodeMajor}.x | bash -`))
64
- ensureOk(c.runIn('apt install -y nodejs'))
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.runIn('apt install -y python3 make g++')),
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.runIn(`npm install -g @anthropic-ai/claude-code@${c.pins.claudeCode}`))
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.runIn(
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
- /** Run one step: guard then action, timed, one structured line. Never throws. */
105
- function runStep(step, ctx) {
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
- step.run(ctx)
153
+ if (ctx.heartbeat) stopHeartbeat = ctx.heartbeat(step.name, start)
154
+ await step.run(ctx)
115
155
  ok = true
116
156
  }
117
- } catch {
118
- ok = false
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
- ctx.log('step', { name: step.name, ok, ms })
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.0",
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": {