@rubytech/create-maxy-lite 0.1.0
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/README.md +61 -0
- package/index.mjs +150 -0
- package/lib/healthcheck.mjs +41 -0
- package/lib/log.mjs +16 -0
- package/lib/orchestrate.mjs +148 -0
- package/lib/paths.mjs +50 -0
- package/lib/pins.mjs +22 -0
- package/package.json +33 -0
- package/payload/package.json +8 -0
- package/payload/schema/SCHEMA.md +404 -0
- package/payload/validator/cli.mjs +56 -0
- package/payload/validator/frontmatter.mjs +37 -0
- package/payload/validator/schema.mjs +147 -0
- package/payload/validator/validate.mjs +166 -0
- package/payload/webchat/README.md +55 -0
- package/payload/webchat/package-lock.json +56 -0
- package/payload/webchat/package.json +17 -0
- package/payload/webchat/parse-transcript.mjs +128 -0
- package/payload/webchat/public/app.js +106 -0
- package/payload/webchat/public/index.html +32 -0
- package/payload/webchat/public/style.css +161 -0
- package/payload/webchat/server.mjs +229 -0
- package/versions.json +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @rubytech/create-maxy-lite
|
|
2
|
+
|
|
3
|
+
The maxy-lite installer: one command that puts the `claude` code binary on an Android phone, controlled from the phone browser. Run it in bare Termux; it orchestrates the whole on-device stack.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# In Termux (from F-Droid):
|
|
9
|
+
pkg install -y nodejs
|
|
10
|
+
npx @rubytech/create-maxy-lite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The single `npx` does everything, idempotently:
|
|
14
|
+
|
|
15
|
+
1. `termux-deps` — Termux packages (`proot-distro`) for the glibc layer
|
|
16
|
+
2. `proot` — verify the proot-distro framework is operational
|
|
17
|
+
3. `ubuntu` — install the glibc Ubuntu rootfs under proot
|
|
18
|
+
4. `node` — pinned glibc Node inside Ubuntu (the bionic Node cannot run `claude`)
|
|
19
|
+
5. `toolchain` — `python3`/`make`/`g++` so `node-pty` compiles
|
|
20
|
+
6. `vault-bind` — the shared-storage vault plus the proot bind so Obsidian and `claude` share one folder
|
|
21
|
+
7. `npm-app` — pinned `@anthropic-ai/claude-code` and `ttyd`, the app (validator + web-chat relay), and the `maxy-lite` launcher
|
|
22
|
+
|
|
23
|
+
Re-running reconciles to the same state without duplicating anything. `--dry-run` prints the plan and pinned versions without touching the device.
|
|
24
|
+
|
|
25
|
+
## Why a Node-only installer works in bare Termux
|
|
26
|
+
|
|
27
|
+
`claude` ships a glibc-only native binary, so the *agent* must run inside a proot-distro Ubuntu layer. But the *installer* is plain Node and runs fine on Termux's bionic Node, so there is no hosted shell script to maintain. A `curl … | bash` bootstrap exists only as a documented alternative (it just installs the Node prerequisite and calls this same package).
|
|
28
|
+
|
|
29
|
+
## Layout
|
|
30
|
+
|
|
31
|
+
| Path | What |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `index.mjs` | Entry: wires the real subprocess/fs/probe primitives and runs the orchestrator. |
|
|
34
|
+
| `lib/orchestrate.mjs` | The guarded, idempotent step sequence. |
|
|
35
|
+
| `lib/healthcheck.mjs` | The four post-install probes (claude, webchat, validator, vault). |
|
|
36
|
+
| `lib/pins.mjs`, `versions.json` | Pinned Node major, claude-code and ttyd versions. |
|
|
37
|
+
| `lib/paths.mjs` | On-device paths and the `maxy-lite` launcher. |
|
|
38
|
+
| `payload/` | Bundled app components (schema, validator, webchat), generated by `npm run bundle`. |
|
|
39
|
+
|
|
40
|
+
The `payload/` directory is gitignored and rebuilt at publish time from the sibling `maxy-lite` component tree; edit the sources there, never the payload.
|
|
41
|
+
|
|
42
|
+
## Observability
|
|
43
|
+
|
|
44
|
+
Every step emits one structured line so a failed install is diagnosable from its log:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[lite-install] op=step name=<…> ok=<bool> ms=<…>
|
|
48
|
+
[lite-install] op=versions node=<v> claude=<v> ttyd=<v>
|
|
49
|
+
[lite-install] op=healthcheck claude=<bool> webchat=<bool> validator=<bool> vault=<bool>
|
|
50
|
+
[lite-install] op=done ok=<bool> ranMs=<…>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A healthy run ends `op=done ok=true`. The first `ok=false` step is the failure point. Full operator instructions: `maxy-code/.docs/maxy-lite-install.md`.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm test # node --test over lib (log, pins, paths, healthcheck, orchestrate, bundle)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The validator probe runs the real bundled validator against an empty vault; the orchestrator tests assert the step order, the idempotency guards, and the structured-line output against a recording context.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @rubytech/create-maxy-lite — the maxy-lite installer.
|
|
3
|
+
//
|
|
4
|
+
// Run in bare Termux (bionic Node) via `npx @rubytech/create-maxy-lite`. It
|
|
5
|
+
// orchestrates the whole on-device install: proot-distro Ubuntu -> glibc Node ->
|
|
6
|
+
// claude -> the app (validator + web-chat relay) -> the shared-storage vault and
|
|
7
|
+
// its proot bind-mount. Every step emits one `[lite-install]` line and the run
|
|
8
|
+
// exits non-zero on any failed step or health-check probe.
|
|
9
|
+
//
|
|
10
|
+
// This file is only the wiring: it builds the real subprocess/fs/probe
|
|
11
|
+
// primitives and hands them to orchestrate(). The sequence, guards, idempotency
|
|
12
|
+
// and probe rules live in ./lib and are unit-tested there.
|
|
13
|
+
//
|
|
14
|
+
// Subprocesses use spawnSync with an explicit (file, args[]) — never exec() and
|
|
15
|
+
// never shell:true. The command text is interpreted by an explicit `bash -lc` so
|
|
16
|
+
// pipes work; every command is a pinned constant from versions.json/paths.mjs,
|
|
17
|
+
// never user input, so there is no injection surface.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
import { makeLog } from './lib/log.mjs'
|
|
25
|
+
import { readPins } from './lib/pins.mjs'
|
|
26
|
+
import { PATHS, WEBCHAT_DIR, VALIDATOR_CLI, launcherPath, launcherScript } from './lib/paths.mjs'
|
|
27
|
+
import { orchestrate, STEP_NAMES } from './lib/orchestrate.mjs'
|
|
28
|
+
import { claudeOk, validatorOk, vaultOk, webchatOk, healthcheck } from './lib/healthcheck.mjs'
|
|
29
|
+
|
|
30
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const PAYLOAD = path.join(HERE, 'payload')
|
|
32
|
+
const PINS = readPins(path.join(HERE, 'versions.json'))
|
|
33
|
+
const PREFIX = process.env.PREFIX || '/data/data/com.termux/files/usr'
|
|
34
|
+
|
|
35
|
+
// ---- subprocess primitives --------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const decode = (r) => ({ code: r.status === null ? 1 : r.status, stdout: r.stdout || '' })
|
|
38
|
+
|
|
39
|
+
/** Run a command in bare Termux. */
|
|
40
|
+
const run = (cmd) => decode(spawnSync('bash', ['-lc', cmd], { encoding: 'utf8' }))
|
|
41
|
+
|
|
42
|
+
/** Run a command inside the glibc Ubuntu layer. */
|
|
43
|
+
const runIn = (cmd) =>
|
|
44
|
+
decode(spawnSync('proot-distro', ['login', PATHS.distro, '--', 'bash', '-lc', cmd], { encoding: 'utf8' }))
|
|
45
|
+
|
|
46
|
+
// ---- filesystem primitives --------------------------------------------------
|
|
47
|
+
// The Ubuntu rootfs is a normal directory under the Termux prefix, so the app
|
|
48
|
+
// payload is copied into it directly from bionic Node (no proot round-trip).
|
|
49
|
+
|
|
50
|
+
const rootfs = `${PREFIX}/var/lib/proot-distro/installed-rootfs/${PATHS.distro}`
|
|
51
|
+
const appHostPath = `${rootfs}${PATHS.appDir}` // appDir is absolute inside the rootfs
|
|
52
|
+
|
|
53
|
+
const existsHost = (p) => fs.existsSync(p)
|
|
54
|
+
const mkdirHost = (p) => fs.mkdirSync(p, { recursive: true })
|
|
55
|
+
|
|
56
|
+
/** Copy the bundled app payload into the Ubuntu rootfs (idempotent overwrite). */
|
|
57
|
+
const layPayload = () => {
|
|
58
|
+
fs.mkdirSync(appHostPath, { recursive: true })
|
|
59
|
+
fs.cpSync(PAYLOAD, appHostPath, { recursive: true })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 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')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Write the `maxy-lite` launcher into the Termux bin dir. */
|
|
69
|
+
const writeLauncher = () => {
|
|
70
|
+
fs.writeFileSync(launcherPath(PREFIX), launcherScript({ prefix: PREFIX }), { mode: 0o755 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- measured versions ------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const firstMatch = (s, re, fallback) => {
|
|
76
|
+
const m = re.exec(s || '')
|
|
77
|
+
return m ? m[1] : fallback
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const readVersions = () => ({
|
|
81
|
+
node: firstMatch(runIn('node -v').stdout, /v(\d+\.\d+\.\d+)/, PINS.nodeMajor),
|
|
82
|
+
claude: firstMatch(runIn('claude --version').stdout, /(\d+\.\d+\.\d+)/, PINS.claudeCode),
|
|
83
|
+
ttyd: firstMatch(runIn('ttyd --version').stdout, /(\d+\.\d+\.\d+)/, PINS.ttyd),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ---- health-check probes (the measured post-conditions) ---------------------
|
|
87
|
+
|
|
88
|
+
const runHealthcheck = () =>
|
|
89
|
+
healthcheck({
|
|
90
|
+
checkClaude: () => claudeOk(() => runIn('claude --version')),
|
|
91
|
+
// Start the relay, poll its port for up to 10s, record whether it bound, kill it.
|
|
92
|
+
checkWebchat: () =>
|
|
93
|
+
webchatOk(async () => {
|
|
94
|
+
const probe =
|
|
95
|
+
`cd ${WEBCHAT_DIR} && node server.mjs & SVPID=$!; ` +
|
|
96
|
+
`for i in $(seq 1 10); do sleep 1; ` +
|
|
97
|
+
`curl -sf -o /dev/null http://localhost:${PATHS.webchatPort} && break; done; ` +
|
|
98
|
+
`curl -sf -o /dev/null http://localhost:${PATHS.webchatPort}; RC=$?; ` +
|
|
99
|
+
`kill $SVPID 2>/dev/null; exit $RC`
|
|
100
|
+
return runIn(probe).code === 0
|
|
101
|
+
}),
|
|
102
|
+
// Validator must exit 0 on an empty vault.
|
|
103
|
+
checkValidator: () => {
|
|
104
|
+
const empty = `${PATHS.appDir}/.hc-empty-vault`
|
|
105
|
+
runIn(`mkdir -p ${empty}`)
|
|
106
|
+
return validatorOk((vault) => runIn(`node ${VALIDATOR_CLI} ${vault}`).code, empty)
|
|
107
|
+
},
|
|
108
|
+
checkVault: () => vaultOk(() => runIn(`test -d ${PATHS.vaultGuest}`).code === 0),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---- dry run ----------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function printDryRun(log) {
|
|
114
|
+
log('plan', { steps: STEP_NAMES.join('>'), node: PINS.nodeMajor, claude: PINS.claudeCode, ttyd: PINS.ttyd })
|
|
115
|
+
log('plan', { vaultHost: PATHS.vaultHost, vaultGuest: PATHS.vaultGuest, appDir: PATHS.appDir })
|
|
116
|
+
log('plan', { launcher: launcherPath(PREFIX), webchatPort: PATHS.webchatPort })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- entry ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async function main() {
|
|
122
|
+
const log = makeLog()
|
|
123
|
+
if (process.argv.includes('--dry-run')) {
|
|
124
|
+
printDryRun(log)
|
|
125
|
+
return 0
|
|
126
|
+
}
|
|
127
|
+
const ctx = {
|
|
128
|
+
run,
|
|
129
|
+
runIn,
|
|
130
|
+
existsHost,
|
|
131
|
+
mkdirHost,
|
|
132
|
+
layPayload,
|
|
133
|
+
installDeps,
|
|
134
|
+
writeLauncher,
|
|
135
|
+
readVersions,
|
|
136
|
+
runHealthcheck,
|
|
137
|
+
pins: PINS,
|
|
138
|
+
now: () => Date.now(),
|
|
139
|
+
log,
|
|
140
|
+
}
|
|
141
|
+
const result = await orchestrate(ctx)
|
|
142
|
+
return result.ok ? 0 : 1
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main()
|
|
146
|
+
.then((code) => process.exit(code))
|
|
147
|
+
.catch((err) => {
|
|
148
|
+
process.stderr.write(`[lite-install] op=done ok=false error=${err && err.message ? err.message : err}\n`)
|
|
149
|
+
process.exit(1)
|
|
150
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Post-install health check — the measured post-conditions, not "ran the step".
|
|
2
|
+
// Each probe answers a binary question about the running system:
|
|
3
|
+
// claude — does `claude --version` answer with a version?
|
|
4
|
+
// webchat — does the relay port listen?
|
|
5
|
+
// validator — does the validator exit 0 on an empty vault?
|
|
6
|
+
// vault — does the bind-mount path exist inside Ubuntu?
|
|
7
|
+
// The probes take injected primitives so the decision rules are unit-testable;
|
|
8
|
+
// index.mjs wires the real ones (run-in-Ubuntu, transient port probe, fs stat).
|
|
9
|
+
|
|
10
|
+
const VERSION_RE = /\d+\.\d+\.\d+/
|
|
11
|
+
|
|
12
|
+
/** True when `claude --version` exits 0 and prints a version. `runIn(cmd)` → {code, stdout}. */
|
|
13
|
+
export function claudeOk(runVersion) {
|
|
14
|
+
const r = runVersion()
|
|
15
|
+
return r.code === 0 && VERSION_RE.test(r.stdout || '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True when the validator exits 0 on the given (empty) vault. `runValidator(vault)` → exit code. */
|
|
19
|
+
export function validatorOk(runValidator, vaultPath) {
|
|
20
|
+
return runValidator(vaultPath) === 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when the bind-mount path exists. `exists()` → bool. */
|
|
24
|
+
export function vaultOk(exists) {
|
|
25
|
+
return exists() === true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the relay port responded. `getStatus()` → Promise<bool>. */
|
|
29
|
+
export async function webchatOk(getStatus) {
|
|
30
|
+
return (await getStatus()) === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run all four probes and return `{ claude, webchat, validator, vault }`. */
|
|
34
|
+
export async function healthcheck({ checkClaude, checkWebchat, checkValidator, checkVault }) {
|
|
35
|
+
return {
|
|
36
|
+
claude: checkClaude() === true,
|
|
37
|
+
webchat: (await checkWebchat()) === true,
|
|
38
|
+
validator: checkValidator() === true,
|
|
39
|
+
vault: checkVault() === true,
|
|
40
|
+
}
|
|
41
|
+
}
|
package/lib/log.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Structured install log. Every install step emits exactly one `[lite-install]`
|
|
2
|
+
// line so a failed install is diagnosable from its log without re-running blind:
|
|
3
|
+
// bootstrap 2>&1 | tee install.log; grep '\[lite-install\]' install.log
|
|
4
|
+
// The first `ok=false` step is the failure point.
|
|
5
|
+
|
|
6
|
+
/** Format one structured line: `[lite-install] op=<op> k=v k=v …` (field order preserved). */
|
|
7
|
+
export function formatLine(op, fields = {}) {
|
|
8
|
+
const parts = [`[lite-install] op=${op}`]
|
|
9
|
+
for (const [k, v] of Object.entries(fields)) parts.push(`${k}=${v}`)
|
|
10
|
+
return parts.join(' ')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Wrap a writer (default: stderr) into `log(op, fields)` that emits one formatted line. */
|
|
14
|
+
export function makeLog(write = (s) => process.stderr.write(s + '\n')) {
|
|
15
|
+
return (op, fields = {}) => write(formatLine(op, fields))
|
|
16
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The install sequence. Run in bare Termux (bionic Node); each step provisions
|
|
2
|
+
// one layer of the runtime stack and emits exactly one `[lite-install] op=step`
|
|
3
|
+
// line, gated on exit code. Every step has a `done(ctx)` guard so a re-run on a
|
|
4
|
+
// converged device skips the mutation and still reports ok=true (idempotent).
|
|
5
|
+
//
|
|
6
|
+
// Steps, in order:
|
|
7
|
+
// termux-deps — Termux packages (proot-distro) for the glibc layer
|
|
8
|
+
// proot — verify the proot-distro framework is operational
|
|
9
|
+
// ubuntu — install the glibc Ubuntu rootfs under proot
|
|
10
|
+
// node — pinned glibc Node inside Ubuntu (the bionic Node can't run claude)
|
|
11
|
+
// toolchain — python3/make/g++ so node-pty compiles
|
|
12
|
+
// vault-bind — shared-storage vault + the proot bind so Obsidian and claude share it
|
|
13
|
+
// npm-app — pinned claude-code + ttyd, the app payload, webchat deps, the launcher
|
|
14
|
+
//
|
|
15
|
+
// All external effects go through injected ctx primitives so the sequence,
|
|
16
|
+
// guards and idempotency are unit-testable; index.mjs wires the real ones.
|
|
17
|
+
|
|
18
|
+
import { PATHS, WEBCHAT_DIR, launcherPath } from './paths.mjs'
|
|
19
|
+
|
|
20
|
+
export const STEP_NAMES = ['termux-deps', 'proot', 'ubuntu', 'node', 'toolchain', 'vault-bind', 'npm-app']
|
|
21
|
+
|
|
22
|
+
/** Throw unless the command result is a clean exit. */
|
|
23
|
+
function ensureOk(r) {
|
|
24
|
+
if (!r || r.code !== 0) throw new Error(`command failed (code ${r ? r.code : 'none'})`)
|
|
25
|
+
return r
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when the glibc Node inside Ubuntu matches the pinned major. */
|
|
35
|
+
function nodeMajorOk(c) {
|
|
36
|
+
const r = c.runIn('node -v')
|
|
37
|
+
if (r.code !== 0) return false
|
|
38
|
+
const m = /^v(\d+)\./.exec((r.stdout || '').trim())
|
|
39
|
+
return !!m && m[1] === String(c.pins.nodeMajor)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STEPS = [
|
|
43
|
+
{
|
|
44
|
+
name: 'termux-deps',
|
|
45
|
+
done: (c) => c.run('command -v proot-distro').code === 0,
|
|
46
|
+
run: (c) => ensureOk(c.run('pkg install -y proot-distro')),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'proot',
|
|
50
|
+
done: () => false, // read-only verification gate; always runs, never mutates
|
|
51
|
+
run: (c) => ensureOk(c.run('proot-distro list')),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'ubuntu',
|
|
55
|
+
done: (c) => c.existsHost(rootfsPath()),
|
|
56
|
+
run: (c) => ensureOk(c.run(`proot-distro install ${PATHS.distro}`)),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'node',
|
|
60
|
+
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'))
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'toolchain',
|
|
69
|
+
done: (c) => c.runIn('command -v g++').code === 0,
|
|
70
|
+
run: (c) => ensureOk(c.runIn('apt install -y python3 make g++')),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'vault-bind',
|
|
74
|
+
done: (c) => c.existsHost(PATHS.vaultHost),
|
|
75
|
+
run: (c) => {
|
|
76
|
+
c.mkdirHost(PATHS.vaultHost)
|
|
77
|
+
ensureOk(c.run(`proot-distro login ${PATHS.distro} --bind ${PATHS.vaultHost}:${PATHS.vaultGuest} -- test -d ${PATHS.vaultGuest}`))
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'npm-app',
|
|
82
|
+
// Converged only when the deps are built, claude is present, AND the launcher
|
|
83
|
+
// is written — the launcher is the last mutation, so guarding on the first two
|
|
84
|
+
// alone would skip a half-finished install and report ok=true with no launcher.
|
|
85
|
+
done: (c) =>
|
|
86
|
+
c.runIn(`test -d ${WEBCHAT_DIR}/node_modules`).code === 0 &&
|
|
87
|
+
c.runIn('command -v claude').code === 0 &&
|
|
88
|
+
c.existsHost(launcherPath()),
|
|
89
|
+
run: (c) => {
|
|
90
|
+
ensureOk(c.runIn(`npm install -g @anthropic-ai/claude-code@${c.pins.claudeCode}`))
|
|
91
|
+
// Pin ttyd to the manifest version via its release binary; apt would float.
|
|
92
|
+
ensureOk(
|
|
93
|
+
c.runIn(
|
|
94
|
+
`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
|
+
),
|
|
96
|
+
)
|
|
97
|
+
c.layPayload()
|
|
98
|
+
c.installDeps()
|
|
99
|
+
c.writeLauncher()
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
/** Run one step: guard then action, timed, one structured line. Never throws. */
|
|
105
|
+
function runStep(step, ctx) {
|
|
106
|
+
const start = ctx.now()
|
|
107
|
+
let ok = false
|
|
108
|
+
let skipped = false
|
|
109
|
+
try {
|
|
110
|
+
if (step.done(ctx)) {
|
|
111
|
+
skipped = true
|
|
112
|
+
ok = true
|
|
113
|
+
} else {
|
|
114
|
+
step.run(ctx)
|
|
115
|
+
ok = true
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
ok = false
|
|
119
|
+
}
|
|
120
|
+
const ms = ctx.now() - start
|
|
121
|
+
ctx.log('step', { name: step.name, ok, ms })
|
|
122
|
+
return { name: step.name, ok, skipped, ms }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run the full install. Stops at the first failed step (logs `done ok=false`,
|
|
127
|
+
* no healthcheck). On success: emits the resolved versions, the four-probe
|
|
128
|
+
* healthcheck, and `done ok=<all probes true>`.
|
|
129
|
+
*/
|
|
130
|
+
export async function orchestrate(ctx) {
|
|
131
|
+
const t0 = ctx.now()
|
|
132
|
+
const results = []
|
|
133
|
+
for (const step of STEPS) {
|
|
134
|
+
const r = runStep(step, ctx)
|
|
135
|
+
results.push(r)
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
ctx.log('done', { ok: false, ranMs: ctx.now() - t0 })
|
|
138
|
+
return { ok: false, results }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const v = ctx.readVersions()
|
|
142
|
+
ctx.log('versions', { node: v.node, claude: v.claude, ttyd: v.ttyd })
|
|
143
|
+
const hc = await ctx.runHealthcheck()
|
|
144
|
+
ctx.log('healthcheck', hc)
|
|
145
|
+
const ok = Object.values(hc).every(Boolean)
|
|
146
|
+
ctx.log('done', { ok, ranMs: ctx.now() - t0 })
|
|
147
|
+
return { ok, results, healthcheck: hc }
|
|
148
|
+
}
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// On-device paths and the launcher.
|
|
2
|
+
//
|
|
3
|
+
// Two distinct surfaces, deliberately separated:
|
|
4
|
+
// - The VAULT (markdown the agent reads/writes) lives on Android shared
|
|
5
|
+
// storage so Obsidian — an Android app — can open it as a vault. It is
|
|
6
|
+
// bind-mounted into the Ubuntu rootfs so `claude` (in proot) sees the same
|
|
7
|
+
// folder. Obsidian and claude share one tree.
|
|
8
|
+
// - The APP code (validator, webchat, its node_modules with native node-pty)
|
|
9
|
+
// lives INSIDE the Ubuntu rootfs. Android shared storage is commonly mounted
|
|
10
|
+
// noexec / no-symlink, which breaks a native node_modules; the rootfs is a
|
|
11
|
+
// normal ext4-backed tree, so the app and its build live there.
|
|
12
|
+
|
|
13
|
+
export const PATHS = {
|
|
14
|
+
distro: 'ubuntu',
|
|
15
|
+
vaultHost: '/storage/emulated/0/maxy', // Android shared storage (Obsidian opens this)
|
|
16
|
+
vaultGuest: '/root/maxy', // bind target inside Ubuntu (claude's working dir)
|
|
17
|
+
appDir: '/root/.maxy-lite/app', // app code + node_modules, in the rootfs
|
|
18
|
+
webchatPort: 7682,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const WEBCHAT_DIR = `${PATHS.appDir}/webchat`
|
|
22
|
+
export const VALIDATOR_CLI = `${PATHS.appDir}/validator/cli.mjs`
|
|
23
|
+
|
|
24
|
+
/** Absolute path of the `maxy-lite` launcher in the Termux bin dir. */
|
|
25
|
+
export function launcherPath(prefix = process.env.PREFIX || '/data/data/com.termux/files/usr') {
|
|
26
|
+
return `${prefix}/bin/maxy-lite`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `maxy-lite` launcher script (written into the Termux bin dir). Run from the
|
|
31
|
+
* Termux shell, it enters Ubuntu with the vault bind-mount and starts the webchat
|
|
32
|
+
* relay on localhost. Documented Termux:Boot opt-in re-runs this on boot.
|
|
33
|
+
*/
|
|
34
|
+
export function launcherScript({
|
|
35
|
+
prefix = process.env.PREFIX || '/data/data/com.termux/files/usr',
|
|
36
|
+
vaultHost = PATHS.vaultHost,
|
|
37
|
+
vaultGuest = PATHS.vaultGuest,
|
|
38
|
+
webchatDir = WEBCHAT_DIR,
|
|
39
|
+
port = PATHS.webchatPort,
|
|
40
|
+
distro = PATHS.distro,
|
|
41
|
+
} = {}) {
|
|
42
|
+
return `#!${prefix}/bin/bash
|
|
43
|
+
# maxy-lite launcher — starts the on-device web chat relay.
|
|
44
|
+
# Enters the glibc Ubuntu layer with the shared-storage vault bound in, then runs
|
|
45
|
+
# the relay on http://localhost:${port}. Open that URL in the phone browser.
|
|
46
|
+
set -e
|
|
47
|
+
exec proot-distro login ${distro} --bind ${vaultHost}:${vaultGuest} -- \\
|
|
48
|
+
bash -lc 'cd ${webchatDir} && LITE_AGENT_HOME=${vaultGuest} LITE_PORT=${port} exec node server.mjs'
|
|
49
|
+
`
|
|
50
|
+
}
|
package/lib/pins.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Version pins — one source of truth (versions.json). The installer pins Node,
|
|
2
|
+
// claude-code and ttyd so a fresh device gets reproducible versions, never a
|
|
3
|
+
// floating `latest`. Read once at startup; `op=versions` reports what landed.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const REQUIRED = ['nodeMajor', 'claudeCode', 'ttyd']
|
|
8
|
+
|
|
9
|
+
/** Read and validate the pinned versions. Throws on a missing, empty or floating pin. */
|
|
10
|
+
export function readPins(manifestPath) {
|
|
11
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
12
|
+
for (const key of REQUIRED) {
|
|
13
|
+
const v = raw[key]
|
|
14
|
+
if (v === undefined || v === null || String(v).trim() === '') {
|
|
15
|
+
throw new Error(`pins: ${key} is required in ${manifestPath}`)
|
|
16
|
+
}
|
|
17
|
+
if (String(v).trim().toLowerCase() === 'latest') {
|
|
18
|
+
throw new Error(`pins: ${key} must be a fixed version, not "latest"`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { nodeMajor: String(raw.nodeMajor), claudeCode: String(raw.claudeCode), ttyd: String(raw.ttyd) }
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rubytech/create-maxy-lite",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"create-maxy-lite": "index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.mjs",
|
|
14
|
+
"lib",
|
|
15
|
+
"versions.json",
|
|
16
|
+
"payload",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"bundle": "node scripts/bundle.mjs",
|
|
21
|
+
"test": "node --test 'test/*.test.mjs'",
|
|
22
|
+
"prepublishOnly": "node scripts/bundle.mjs"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"maxy-lite",
|
|
26
|
+
"android",
|
|
27
|
+
"termux",
|
|
28
|
+
"claude",
|
|
29
|
+
"on-device"
|
|
30
|
+
],
|
|
31
|
+
"author": "Rubytech LLC",
|
|
32
|
+
"license": "UNLICENSED"
|
|
33
|
+
}
|