@mobius-os/mobius 0.2.9 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/aimux.ts +133 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "dependencies": {
19
19
  "chalk": "^5.3.0",
20
20
  "cli-highlight": "2.1.11",
21
+ "extract-zip": "^2.0.1",
21
22
  "ink": "5.2.0",
22
23
  "marked": "12.0.2",
23
24
  "react": "18.3.1",
package/src/aimux.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * currently authenticated Mobius server. Nothing is started before login.
7
7
  */
8
8
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
9
- import { promises as fs, existsSync } from 'node:fs'
9
+ import { promises as fs, existsSync, createWriteStream } from 'node:fs'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
+ import { Readable } from 'node:stream'
13
+ import extract from 'extract-zip'
12
14
  import { mobiusHome } from './config.js'
13
15
 
14
16
  export type AimuxState = 'starting' | 'connected' | 'failed' | 'stopped' | 'disabled'
@@ -22,6 +24,11 @@ export interface AimuxStatus {
22
24
  }
23
25
  export interface InstallProgress { phase: 'python' | 'venv' | 'install' | 'ready'; detail?: string }
24
26
 
27
+ /** aimux 的调用方式:venv 直接执行,或用内置 python 跑 `-m aimux`(Plan B 兜底)。 */
28
+ export type AimuxLauncher =
29
+ | { kind: 'exe'; path: string }
30
+ | { kind: 'module'; python: string }
31
+
25
32
  const AIMUX_PACKAGE = 'aimux'
26
33
  const WIN = process.platform === 'win32'
27
34
  const venvDir = () => path.join(mobiusHome(), 'aimux-venv')
@@ -76,21 +83,125 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
76
83
  return findPython() ?? installPython(onProgress)
77
84
  }
78
85
 
79
- export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string }> {
80
- if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true } }
86
+ // ── Plan B: 内置 python+aimux 运行时(本地 venv/pip 失败时的离线兜底)─────────
87
+ // 一个 zip 内含完整的 python-build-standalone(自带 ensurepip+pip)+ 预装 aimux;
88
+ // 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
89
+ // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
90
+ // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
91
+ const BUNDLE_VER = '1'
92
+ const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
93
+ const bundlePython = () => WIN
94
+ ? path.join(bundleDir(), 'python', 'python.exe')
95
+ : path.join(bundleDir(), 'python', 'bin', 'python3')
96
+
97
+ /** 当前平台对应的内置运行时包名;mac-arm64 走 mac-x64(Rosetta 2)。 */
98
+ export function bundleArch(): string | null {
99
+ const { platform, arch } = process
100
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64'
101
+ if (platform === 'win32' && arch === 'x64') return 'win-x64'
102
+ if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return 'mac-x64'
103
+ return null
104
+ }
105
+
106
+ /** CDN 基址可用 MOBIUS_TUI_PYTHON_BUNDLE_URL 覆盖;文件名固定为 mobius-python-<arch>-v<N>.zip。 */
107
+ export const bundleBaseUrl = () => (process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL || 'https://serve.nutshellai.cn/publish/auto/mobius-tui').replace(/\/$/, '')
108
+ export const bundleUrl = (arch: string) => `${bundleBaseUrl()}/mobius-python-${arch}-v${BUNDLE_VER}.zip`
109
+
110
+ function bundleReady(): boolean {
111
+ return existsSync(bundlePython()) && spawnSync(bundlePython(), ['-c', 'import aimux'], { stdio: 'ignore', windowsHide: true }).status === 0
112
+ }
113
+
114
+ async function downloadBundle(arch: string, onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; zipPath?: string }> {
115
+ const url = bundleUrl(arch)
116
+ const zipPath = path.join(mobiusHome(), `python-bundle-v${BUNDLE_VER}.zip.tmp`)
117
+ await fs.mkdir(path.dirname(zipPath), { recursive: true }) // 首次安装 ~/.mobius 可能尚未创建
118
+ let res: Response
119
+ try { res = await fetch(url) } catch (e: any) { return { ok: false, error: `下载失败: ${e?.message ?? e}` } }
120
+ if (!res.ok) return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` }
121
+ const total = Number(res.headers.get('content-length') || 0)
122
+ const ws = createWriteStream(zipPath)
123
+ let got = 0, last = 0
124
+ try {
125
+ const stream = Readable.fromWeb(res.body as any)
126
+ for await (const chunk of stream) {
127
+ ws.write(chunk as Buffer)
128
+ got += (chunk as Buffer).length
129
+ if (total && got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) }
130
+ }
131
+ if (!total && got) onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
132
+ await new Promise<void>((resolve, reject) => { ws.end(() => resolve()); ws.on('error', reject) })
133
+ } catch (e: any) {
134
+ try { ws.destroy() } catch {}
135
+ try { await fs.unlink(zipPath) } catch {}
136
+ return { ok: false, error: `下载失败: ${e?.message ?? e}` }
137
+ }
138
+ return { ok: true, zipPath }
139
+ }
140
+
141
+ async function extractBundle(zipPath: string): Promise<{ ok: boolean; error?: string }> {
142
+ const staging = path.join(mobiusHome(), 'python-bundle.new')
143
+ const finalDir = bundleDir()
144
+ const stagingPython = WIN ? path.join(staging, 'python', 'python.exe') : path.join(staging, 'python', 'bin', 'python3')
145
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
146
+ await fs.mkdir(staging, { recursive: true })
147
+ try { await extract(zipPath, { dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644 }) }
148
+ catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
149
+ if (!WIN) try { await fs.chmod(stagingPython, 0o755) } catch {} // 保险:确保可执行位(extract-zip 通常已还原)
150
+ await fs.rm(finalDir, { recursive: true, force: true }).catch(() => {})
151
+ await fs.rename(staging, finalDir)
152
+ return { ok: true }
153
+ }
154
+
155
+ export async function ensureFromBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
156
+ if (bundleReady()) return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
157
+ const arch = bundleArch()
158
+ if (!arch) return { ok: false, error: `当前平台无内置运行时 (platform=${process.platform} arch=${process.arch})` }
159
+ onProgress?.({ phase: 'install', detail: `下载内置运行时 (${arch})…` })
160
+ const dl = await downloadBundle(arch, onProgress)
161
+ if (!dl.ok || !dl.zipPath) return { ok: false, error: dl.error }
162
+ onProgress?.({ phase: 'install', detail: '解压内置运行时…' })
163
+ const ex = await extractBundle(dl.zipPath)
164
+ try { await fs.unlink(dl.zipPath) } catch {}
165
+ if (!ex.ok) return { ok: false, error: ex.error }
166
+ if (!bundleReady()) return { ok: false, error: '内置运行时解压后仍无法 import aimux' }
167
+ return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
168
+ }
169
+
170
+ /** 按 launcher 把 aimux 参数变成实际 spawn。 */
171
+ export function spawnLauncher(launcher: AimuxLauncher, args: string[]): ChildProcess {
172
+ return launcher.kind === 'exe'
173
+ ? spawn(launcher.path, args, { windowsHide: true })
174
+ : spawn(launcher.python, ['-m', 'aimux', ...args], { windowsHide: true })
175
+ }
176
+
177
+ /** test-only 导出: 暴露内部 downloadBundle 以便单测 mock fetch 验证流式下载+进度。 */
178
+ export const downloadBundleForTest = downloadBundle
179
+
180
+ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
181
+ // Fast-path:venv 里已有 aimux 可执行 → 直接用。
182
+ if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
81
183
  const py = await pythonForAimux(onProgress)
82
- if (!py) return { ok: false, error: '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。' }
83
- onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
84
- let r = await run(py, ['-m', 'venv', venvDir()])
85
- if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
86
- if (r.code !== 0) return { ok: false, error: `venv 创建失败: ${r.stderr || r.stdout}` }
87
- onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
88
- r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
89
- if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
90
- })
91
- if (r.code !== 0) return { ok: false, error: `pip install 失败: ${r.stderr || r.stdout}` }
92
- if (!existsSync(aimuxExe())) return { ok: false, error: `aimux 可执行未生成: ${aimuxExe()}` }
93
- onProgress?.({ phase: 'ready' }); return { ok: true }
184
+ let venvError = '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。'
185
+ if (py) {
186
+ onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
187
+ let r = await run(py, ['-m', 'venv', venvDir()])
188
+ if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
189
+ if (r.code === 0) {
190
+ onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
191
+ r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
192
+ if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
193
+ })
194
+ if (r.code === 0 && existsSync(aimuxExe())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
195
+ venvError = r.code === 0 ? `aimux 可执行未生成: ${aimuxExe()}` : `pip install 失败: ${r.stderr || r.stdout}`
196
+ } else {
197
+ venvError = `venv 创建失败: ${r.stderr || r.stdout}`
198
+ }
199
+ }
200
+ // ── Plan B 兜底:本地 python/venv 不可用 → 下载内置 python+aimux 运行时 ──
201
+ onProgress?.({ phase: 'install', detail: '本地 Python 不可用,改用内置运行时…' })
202
+ const bundle = await ensureFromBundle(onProgress)
203
+ if (bundle.ok && bundle.launcher) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: bundle.launcher } }
204
+ return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
94
205
  }
95
206
 
96
207
  export function tuiAimuxIdentifier(): string {
@@ -281,8 +392,13 @@ export async function startAimuxConnection(opts: { server: string; token: string
281
392
  phase: p.phase === 'ready' ? 'connecting' : p.phase,
282
393
  detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
283
394
  }))
284
- if (!ready.ok) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
285
- supervisor = new AimuxSupervisor({ server: opts.server, token: opts.token, identifier: tuiAimuxIdentifier(), onStatus })
395
+ if (!ready.ok || !ready.launcher) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
396
+ const identifier = tuiAimuxIdentifier()
397
+ const launcher = ready.launcher
398
+ supervisor = new AimuxSupervisor({
399
+ server: opts.server, token: opts.token, identifier, onStatus,
400
+ spawnProcess: () => spawnLauncher(launcher, ['reverse', 'connect', `${opts.server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', opts.token, '--replace']),
401
+ })
286
402
  supervisor.start()
287
403
  })().finally(() => { installing = null })
288
404
  await installing