@rubytech/create-maxy-lite 0.1.0 → 0.1.1

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).
@@ -60,9 +89,13 @@ const layPayload = () => {
60
89
  }
61
90
 
62
91
  /** 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')
92
+ // Streamed: the webchat deps build node-pty natively, which is slow and verbose;
93
+ // a failure carries the child's stderr so the cause is in the log.
94
+ const installDeps = async () => {
95
+ const app = await runInStream(`cd ${PATHS.appDir} && npm install --omit=dev`)
96
+ if (app.code !== 0) throw new Error(`app deps install failed: ${app.stderr.replace(/\s+/g, ' ').trim()}`)
97
+ const wc = await runInStream(`cd ${WEBCHAT_DIR} && npm install --omit=dev`)
98
+ if (wc.code !== 0) throw new Error(`webchat deps install failed: ${wc.stderr.replace(/\s+/g, ' ').trim()}`)
66
99
  }
67
100
 
68
101
  /** Write the `maxy-lite` launcher into the Termux bin dir. */
@@ -118,15 +151,28 @@ function printDryRun(log) {
118
151
 
119
152
  // ---- entry ------------------------------------------------------------------
120
153
 
154
+ // A long step emits a liveness heartbeat at least this often, so no silent gap
155
+ // between log lines reads as a hang.
156
+ const HEARTBEAT_MS = 10000
157
+
121
158
  async function main() {
122
159
  const log = makeLog()
123
160
  if (process.argv.includes('--dry-run')) {
124
161
  printDryRun(log)
125
162
  return 0
126
163
  }
164
+ // Periodic liveness for a running step: emit `op=step-progress` every
165
+ // HEARTBEAT_MS until stopped. unref so a stray timer never holds the process open.
166
+ const heartbeat = (name, startMs) => {
167
+ const timer = setInterval(() => log('step-progress', { name, elapsedMs: Date.now() - startMs }), HEARTBEAT_MS)
168
+ if (timer.unref) timer.unref()
169
+ return () => clearInterval(timer)
170
+ }
127
171
  const ctx = {
128
172
  run,
129
173
  runIn,
174
+ runStream,
175
+ runInStream,
130
176
  existsHost,
131
177
  mkdirHost,
132
178
  layPayload,
@@ -137,6 +183,7 @@ async function main() {
137
183
  pins: PINS,
138
184
  now: () => Date.now(),
139
185
  log,
186
+ heartbeat,
140
187
  }
141
188
  const result = await orchestrate(ctx)
142
189
  return result.ok ? 0 : 1
@@ -19,9 +19,13 @@ 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
 
@@ -43,7 +47,8 @@ const STEPS = [
43
47
  {
44
48
  name: 'termux-deps',
45
49
  done: (c) => c.run('command -v proot-distro').code === 0,
46
- run: (c) => ensureOk(c.run('pkg install -y proot-distro')),
50
+ // Long and network-bound (repo sync + download); stream so the operator sees apt progress.
51
+ run: async (c) => ensureOk(await c.runStream('pkg install -y proot-distro')),
47
52
  },
48
53
  {
49
54
  name: 'proot',
@@ -53,21 +58,21 @@ const STEPS = [
53
58
  {
54
59
  name: 'ubuntu',
55
60
  done: (c) => c.existsHost(rootfsPath()),
56
- run: (c) => ensureOk(c.run(`proot-distro install ${PATHS.distro}`)),
61
+ run: async (c) => ensureOk(await c.runStream(`proot-distro install ${PATHS.distro}`)),
57
62
  },
58
63
  {
59
64
  name: 'node',
60
65
  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'))
66
+ run: async (c) => {
67
+ // runInStream already wraps in `bash -lc`; pass the pipeline directly (no inner bash).
68
+ ensureOk(await c.runInStream(`curl -fsSL https://deb.nodesource.com/setup_${c.pins.nodeMajor}.x | bash -`))
69
+ ensureOk(await c.runInStream('apt install -y nodejs'))
65
70
  },
66
71
  },
67
72
  {
68
73
  name: 'toolchain',
69
74
  done: (c) => c.runIn('command -v g++').code === 0,
70
- run: (c) => ensureOk(c.runIn('apt install -y python3 make g++')),
75
+ run: async (c) => ensureOk(await c.runInStream('apt install -y python3 make g++')),
71
76
  },
72
77
  {
73
78
  name: 'vault-bind',
@@ -86,39 +91,53 @@ const STEPS = [
86
91
  c.runIn(`test -d ${WEBCHAT_DIR}/node_modules`).code === 0 &&
87
92
  c.runIn('command -v claude').code === 0 &&
88
93
  c.existsHost(launcherPath()),
89
- run: (c) => {
90
- ensureOk(c.runIn(`npm install -g @anthropic-ai/claude-code@${c.pins.claudeCode}`))
94
+ run: async (c) => {
95
+ ensureOk(await c.runInStream(`npm install -g @anthropic-ai/claude-code@${c.pins.claudeCode}`))
91
96
  // Pin ttyd to the manifest version via its release binary; apt would float.
92
97
  ensureOk(
93
- c.runIn(
98
+ await c.runInStream(
94
99
  `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
100
  ),
96
101
  )
97
102
  c.layPayload()
98
- c.installDeps()
103
+ await c.installDeps()
99
104
  c.writeLauncher()
100
105
  },
101
106
  },
102
107
  ]
103
108
 
104
- /** Run one step: guard then action, timed, one structured line. Never throws. */
105
- function runStep(step, ctx) {
109
+ /**
110
+ * Run one step: announce, guard, then action — timed, one structured line. Never
111
+ * throws. Emits `op=step-start` before any work so the operator always sees what
112
+ * is running; a failed step's `op=step` line carries `err=<message>` (the command's
113
+ * stderr, via ensureOk) so the cause is diagnosable from the log alone. While the
114
+ * action runs, a heartbeat (injected) keeps a long step from looking hung.
115
+ */
116
+ async function runStep(step, ctx) {
106
117
  const start = ctx.now()
118
+ ctx.log('step-start', { name: step.name })
107
119
  let ok = false
108
120
  let skipped = false
121
+ let errMsg = null
122
+ let stopHeartbeat = null
109
123
  try {
110
124
  if (step.done(ctx)) {
111
125
  skipped = true
112
126
  ok = true
113
127
  } else {
114
- step.run(ctx)
128
+ if (ctx.heartbeat) stopHeartbeat = ctx.heartbeat(step.name, start)
129
+ await step.run(ctx)
115
130
  ok = true
116
131
  }
117
- } catch {
118
- ok = false
132
+ } catch (err) {
133
+ errMsg = err && err.message ? err.message : String(err)
134
+ } finally {
135
+ if (stopHeartbeat) stopHeartbeat()
119
136
  }
120
137
  const ms = ctx.now() - start
121
- ctx.log('step', { name: step.name, ok, ms })
138
+ const fields = { name: step.name, ok, ms }
139
+ if (!ok && errMsg) fields.err = errMsg.replace(/\s+/g, ' ').trim().slice(0, 300)
140
+ ctx.log('step', fields)
122
141
  return { name: step.name, ok, skipped, ms }
123
142
  }
124
143
 
@@ -131,7 +150,7 @@ export async function orchestrate(ctx) {
131
150
  const t0 = ctx.now()
132
151
  const results = []
133
152
  for (const step of STEPS) {
134
- const r = runStep(step, ctx)
153
+ const r = await runStep(step, ctx)
135
154
  results.push(r)
136
155
  if (!r.ok) {
137
156
  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.1",
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": {