@rubytech/create-maxy-lite 0.1.1 → 0.1.3

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
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'
23
23
 
24
24
  import { makeLog } from './lib/log.mjs'
25
25
  import { readPins } from './lib/pins.mjs'
26
- import { PATHS, WEBCHAT_DIR, VALIDATOR_CLI, launcherPath, launcherScript } from './lib/paths.mjs'
26
+ import { PATHS, WEBCHAT_DIR, VALIDATOR_CLI, launcherPath, launcherScript, payloadLayCommand } from './lib/paths.mjs'
27
27
  import { orchestrate, STEP_NAMES } from './lib/orchestrate.mjs'
28
28
  import { claudeOk, validatorOk, vaultOk, webchatOk, healthcheck } from './lib/healthcheck.mjs'
29
29
 
@@ -73,19 +73,38 @@ const runStream = (cmd) => spawnStreaming('bash', ['-lc', cmd])
73
73
  const runInStream = (cmd) => spawnStreaming('proot-distro', ['login', PATHS.distro, '--', 'bash', '-lc', cmd])
74
74
 
75
75
  // ---- filesystem primitives --------------------------------------------------
76
- // The Ubuntu rootfs is a normal directory under the Termux prefix, so the app
77
- // payload is copied into it directly from bionic Node (no proot round-trip).
78
-
79
- const rootfs = `${PREFIX}/var/lib/proot-distro/installed-rootfs/${PATHS.distro}`
80
- const appHostPath = `${rootfs}${PATHS.appDir}` // appDir is absolute inside the rootfs
76
+ // Host-side fs is used only for genuine host paths (the Termux-prefix launcher
77
+ // and the shared-storage vault). The app payload lands inside the rootfs through
78
+ // proot (see layPayload), never via a reconstructed host rootfs path.
81
79
 
82
80
  const existsHost = (p) => fs.existsSync(p)
83
81
  const mkdirHost = (p) => fs.mkdirSync(p, { recursive: true })
84
82
 
85
- /** Copy the bundled app payload into the Ubuntu rootfs (idempotent overwrite). */
83
+ // ---- shared-storage permission ----------------------------------------------
84
+ // The vault lives on Android shared storage, whose parent (`/storage/emulated/0`)
85
+ // is unwritable until Termux holds the shared-storage permission. `termux-setup-
86
+ // storage` pops the Android grant dialog; the parent becomes writable once the
87
+ // user taps Allow. Detection is the writability of that parent, not an existence
88
+ // check — the directory can be present but inaccessible before the grant.
89
+
90
+ const storageParent = path.dirname(PATHS.vaultHost)
91
+ const storageAccessible = () => {
92
+ try {
93
+ fs.accessSync(storageParent, fs.constants.W_OK)
94
+ return true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+ const interactive = Boolean(process.stdin.isTTY)
100
+ const STORAGE_POLL_MS = 3000
101
+
102
+ /** Lay the bundled app payload into the guest's appDir through proot (idempotent overwrite). */
103
+ // Delivered via a proot bind + guest-side copy so the payload lands in whatever
104
+ // rootfs proot-distro actually uses — no host-side guess at the rootfs location.
86
105
  const layPayload = () => {
87
- fs.mkdirSync(appHostPath, { recursive: true })
88
- fs.cpSync(PAYLOAD, appHostPath, { recursive: true })
106
+ const r = run(payloadLayCommand({ payloadHost: PAYLOAD }))
107
+ if (r.code !== 0) throw new Error(`payload lay failed: ${r.stderr.replace(/\s+/g, ' ').trim()}`)
89
108
  }
90
109
 
91
110
  /** Install the app deps (validator's js-yaml) and the webchat deps (node-pty, ws). */
@@ -168,6 +187,22 @@ async function main() {
168
187
  if (timer.unref) timer.unref()
169
188
  return () => clearInterval(timer)
170
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
+ }
171
206
  const ctx = {
172
207
  run,
173
208
  runIn,
@@ -175,6 +210,9 @@ async function main() {
175
210
  runInStream,
176
211
  existsHost,
177
212
  mkdirHost,
213
+ storageAccessible,
214
+ interactive,
215
+ ensureStorageGrant,
178
216
  layPayload,
179
217
  installDeps,
180
218
  writeLauncher,
@@ -29,10 +29,34 @@ function ensureOk(r) {
29
29
  return r
30
30
  }
31
31
 
32
- /** The Ubuntu rootfs path under the Termux prefix. */
33
- function rootfsPath() {
34
- const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr'
35
- 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
36
60
  }
37
61
 
38
62
  /** True when the glibc Node inside Ubuntu matches the pinned major. */
@@ -57,7 +81,7 @@ const STEPS = [
57
81
  },
58
82
  {
59
83
  name: 'ubuntu',
60
- done: (c) => c.existsHost(rootfsPath()),
84
+ done: (c) => distroInstalled(c),
61
85
  run: async (c) => ensureOk(await c.runStream(`proot-distro install ${PATHS.distro}`)),
62
86
  },
63
87
  {
@@ -77,7 +101,8 @@ const STEPS = [
77
101
  {
78
102
  name: 'vault-bind',
79
103
  done: (c) => c.existsHost(PATHS.vaultHost),
80
- run: (c) => {
104
+ run: async (c) => {
105
+ await ensureStorageAccessible(c)
81
106
  c.mkdirHost(PATHS.vaultHost)
82
107
  ensureOk(c.run(`proot-distro login ${PATHS.distro} --bind ${PATHS.vaultHost}:${PATHS.vaultGuest} -- test -d ${PATHS.vaultGuest}`))
83
108
  },
package/lib/paths.mjs CHANGED
@@ -21,6 +21,25 @@ export const PATHS = {
21
21
  export const WEBCHAT_DIR = `${PATHS.appDir}/webchat`
22
22
  export const VALIDATOR_CLI = `${PATHS.appDir}/validator/cli.mjs`
23
23
 
24
+ // Where the host payload is bind-mounted inside the guest while it is copied into
25
+ // place. A throwaway path under the rootfs's own /tmp, distinct from the app dir.
26
+ export const PAYLOAD_STAGE = '/tmp/maxy-lite-payload'
27
+
28
+ /**
29
+ * The bare-Termux command that lays the bundled app payload into the guest's
30
+ * `appDir`. It binds the host payload directory into the guest under `stage`,
31
+ * then copies it guest-side — so the payload always lands in whatever rootfs
32
+ * proot-distro actually uses, with no host-side guess at the rootfs location.
33
+ * `payloadHost` is the only environment-dependent path (the npm package's
34
+ * `payload/`), so it is single-quoted; `stage` and `appDir` are pinned constants.
35
+ */
36
+ export function payloadLayCommand({ payloadHost, distro = PATHS.distro, stage = PAYLOAD_STAGE, appDir = PATHS.appDir }) {
37
+ return (
38
+ `proot-distro login ${distro} --bind '${payloadHost}':${stage} -- ` +
39
+ `bash -lc 'mkdir -p ${appDir} && cp -a ${stage}/. ${appDir}/'`
40
+ )
41
+ }
42
+
24
43
  /** Absolute path of the `maxy-lite` launcher in the Termux bin dir. */
25
44
  export function launcherPath(prefix = process.env.PREFIX || '/data/data/com.termux/files/usr') {
26
45
  return `${prefix}/bin/maxy-lite`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-lite",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {