@mobius-os/mobius 0.3.42 → 0.3.45

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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Typing-latency optimizations against Ink 5.2's fixed render pipeline.
3
+ *
4
+ * Profiling a keystroke loop (node --cpu-prof) showed ~40% of typing time
5
+ * inside Ink's per-paint `Output.get()`:
6
+ * stringWidth 13.5% + styledCharsFromTokens 12.4% + Output.get 6.4%
7
+ * + styledCharsToString 5.5% + diffAnsiCodes 3.4%
8
+ *
9
+ * Every paint rebuilds the whole frame: for each write-op line it applies
10
+ * style transformers, tokenizes the ANSI-styled text into styled chars and
11
+ * re-measures widths, then walks the full cell matrix back to a string.
12
+ * While typing, 19 of 22 line strings are byte-identical across paints (only
13
+ * the composer row changes), yet all are re-tokenized every time — that
14
+ * repetition is the lag users feel.
15
+ *
16
+ * Two runtime patches below. Both verify Ink internals first and silently
17
+ * no-op on mismatch (or when MOBIUS_TUI_DISABLE_PAINT_FLUSH=1):
18
+ *
19
+ * 1. usePaintFlushOnInput — Ink hardcodes `throttle(onRender, 32ms)`. React
20
+ * commits a key in <1ms; the paint can then wait out the throttle window.
21
+ * On each editing key we call the throttle's `flush()` right after the
22
+ * commit lands, so the frame paints in the same event-loop turn.
23
+ *
24
+ * 2. installStyledLineCache — replaces `Output.prototype.get` with a faithful
25
+ * reimplementation whose only difference is memoizing
26
+ * `styledCharsFromTokens(tokenize(line))` by the post-transform line
27
+ * string (the exact input tokenize receives, so cached results are
28
+ * byte-identical). Unchanged lines skip the tokenize+measure hot path,
29
+ * which is where the profiled ~40% lives.
30
+ */
31
+
32
+ import { useEffect } from 'react'
33
+ import { useStdin, useStdout } from 'ink'
34
+
35
+ // ── 1. flush the render throttle right after each editing key ────────────────
36
+
37
+ type Flushable = { flush?: () => void }
38
+
39
+ /** Text-editing inputs that should paint immediately (typed text, Enter, backspace). */
40
+ function isEditingInput(input: string): boolean {
41
+ if (!input) return false
42
+ if (input === '\r' || input === '\n') return true
43
+ if (input === '\x7f' || input === '\x08') return true
44
+ // Anything without an ESC lead is typed text (or a paste chunk). Sequences
45
+ // (arrows, mouse, bracketed paste) start with ESC and keep normal pacing.
46
+ return !input.startsWith('\x1b')
47
+ }
48
+
49
+ export function usePaintFlushOnInput(): void {
50
+ const { internal_eventEmitter } = useStdin()
51
+ const { stdout } = useStdout()
52
+
53
+ useEffect(() => {
54
+ if (process.env.MOBIUS_TUI_DISABLE_PAINT_FLUSH === '1') return
55
+ if (!internal_eventEmitter) return
56
+ installStyledLineCache()
57
+ const handler = (chunk: unknown) => {
58
+ if (!isEditingInput(String(chunk))) return
59
+ // React schedules the commit for this key on a setImmediate (scheduler's
60
+ // Immediate priority). Flushing on a microtask would paint the STALE
61
+ // frame before that commit lands. Schedule the flush for after the
62
+ // commit: another setImmediate queued from here runs after the one
63
+ // React already queued (FIFO within the same iteration).
64
+ setImmediate(() => {
65
+ try { getThrottledRender(stdout)?.flush?.() } catch { /* best-effort */ }
66
+ })
67
+ }
68
+ internal_eventEmitter.on('input', handler)
69
+ return () => { internal_eventEmitter.off('input', handler) }
70
+ }, [internal_eventEmitter, stdout])
71
+ }
72
+
73
+ // ── Ink internals access ─────────────────────────────────────────────────────
74
+ // Ink's exports map blocks subpath imports ('ink/build/instances.js' →
75
+ // ERR_PACKAGE_PATH_NOT_EXPORTED), but `import.meta.resolve('ink')` yields the
76
+ // package entry file URL and the internal modules live next to it in the same
77
+ // build directory. Resolving relative to the entry keeps this working in any
78
+ // install layout (local node_modules, global npm prefix).
79
+
80
+ function inkModuleUrl(name: string): string {
81
+ return new URL(name, new URL('.', (import.meta as any).resolve('ink') as string)).href
82
+ }
83
+
84
+ let cachedInstances: WeakMap<object, any> | null | undefined
85
+
86
+ function getThrottledRender(stdout: object): Flushable | null {
87
+ if (cachedInstances === undefined) {
88
+ cachedInstances = null
89
+ void import(inkModuleUrl("instances.js"))
90
+ .then(mod => { cachedInstances = mod.default })
91
+ .catch(() => { /* keep null */ })
92
+ }
93
+ try {
94
+ return cachedInstances?.get(stdout)?.rootNode?.onRender ?? null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ // ── 2. memoize styled-line tokenization across paints ────────────────────────
101
+
102
+ let cacheInstalled = false
103
+
104
+ export function installStyledLineCache(): void {
105
+ if (cacheInstalled || process.env.MOBIUS_TUI_DISABLE_PAINT_FLUSH === '1') return
106
+ cacheInstalled = true
107
+ void (async () => {
108
+ try {
109
+ const [outputMod, at, widestLineMod, stringWidthMod, sliceAnsiMod]: any[] = await Promise.all([
110
+ import(inkModuleUrl("output.js")),
111
+ import('@alcalzone/ansi-tokenize'),
112
+ import('widest-line'),
113
+ import('string-width'),
114
+ import('slice-ansi'),
115
+ ])
116
+ const OutputClass = outputMod.default
117
+ const proto = OutputClass?.prototype
118
+ if (!proto || typeof proto.get !== 'function' || typeof proto.write !== 'function') return
119
+ if (typeof at.styledCharsFromTokens !== 'function' || typeof at.tokenize !== 'function') return
120
+ const widestLine = widestLineMod.default
121
+ const stringWidth = stringWidthMod.default
122
+ const sliceAnsi = sliceAnsiMod.default
123
+
124
+ // Fragile-internals guard: confirm the op shape this Ink version writes.
125
+ const probe = new OutputClass({ width: 4, height: 2 })
126
+ probe.write(0, 0, 'ab', { transformers: [] })
127
+ const op = probe.operations?.[0]
128
+ if (!op || op.type !== 'write' || typeof op.text !== 'string' || !Array.isArray(op.transformers)) return
129
+
130
+ const cache = new Map<string, any[]>()
131
+ const styledCharsOf = (line: string): any[] => {
132
+ const hit = cache.get(line)
133
+ if (hit) return hit
134
+ const chars = at.styledCharsFromTokens(at.tokenize(line)) as any[]
135
+ if (cache.size >= 4096) cache.clear() // bound memory; rebuilt lazily
136
+ cache.set(line, chars)
137
+ return chars
138
+ }
139
+
140
+ const origGet = proto.get
141
+ proto.get = function (this: any) {
142
+ // Faithful reimplementation of Ink 5.2.0 Output.get with one change:
143
+ // the tokenize+styledCharsFromTokens result is memoized by the
144
+ // post-transform line string. Everything else mirrors upstream.
145
+ const output = []
146
+ for (let y = 0; y < this.height; y++) {
147
+ const row = []
148
+ for (let x = 0; x < this.width; x++) {
149
+ row.push({ type: 'char', value: ' ', fullWidth: false, styles: [] })
150
+ }
151
+ output.push(row)
152
+ }
153
+ const clips: any[] = []
154
+ for (const operation of this.operations) {
155
+ if (operation.type === 'clip') clips.push(operation.clip)
156
+ if (operation.type === 'unclip') clips.pop()
157
+ if (operation.type !== 'write') continue
158
+ const { text, transformers } = operation
159
+ let { x, y } = operation
160
+ let lines = text.split('\n')
161
+ const clip = clips.at(-1)
162
+ if (clip) {
163
+ const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'
164
+ const clipVertically = typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'
165
+ if (clipHorizontally) {
166
+ const width = widestLine(text)
167
+ if (x + width < clip.x1 || x > clip.x2) continue
168
+ }
169
+ if (clipVertically) {
170
+ const height = lines.length
171
+ if (y + height < clip.y1 || y > clip.y2) continue
172
+ }
173
+ if (clipHorizontally) {
174
+ lines = lines.map((line: string) => {
175
+ const from = x < clip.x1 ? clip.x1 - x : 0
176
+ const width = stringWidth(line)
177
+ const to = x + width > clip.x2 ? clip.x2 - x : width
178
+ return sliceAnsi(line, from, to)
179
+ })
180
+ if (x < clip.x1) x = clip.x1
181
+ }
182
+ if (clipVertically) {
183
+ const from = y < clip.y1 ? clip.y1 - y : 0
184
+ const height = lines.length
185
+ const to = y + height > clip.y2 ? clip.y2 - y : height
186
+ lines = lines.slice(from, to)
187
+ if (y < clip.y1) y = clip.y1
188
+ }
189
+ }
190
+ let offsetY = 0
191
+ for (const [index, line0] of lines.entries()) {
192
+ const currentLine = output[y + offsetY]
193
+ if (!currentLine) continue
194
+ let line = line0
195
+ for (const transformer of transformers) line = transformer(line, index)
196
+ const characters = styledCharsOf(line)
197
+ let offsetX = x
198
+ for (const character of characters) {
199
+ currentLine[offsetX] = character
200
+ const isWideCharacter = character.fullWidth || character.value.length > 1
201
+ if (isWideCharacter) {
202
+ currentLine[offsetX + 1] = {
203
+ type: 'char', value: '', fullWidth: false, styles: character.styles,
204
+ }
205
+ }
206
+ offsetX += isWideCharacter ? 2 : 1
207
+ }
208
+ offsetY++
209
+ }
210
+ }
211
+ const generatedOutput = output
212
+ .map(line => {
213
+ const lineWithoutEmptyItems = line.filter((item: unknown) => item !== undefined)
214
+ return at.styledCharsToString(lineWithoutEmptyItems).trimEnd()
215
+ })
216
+ .join('\n')
217
+ return { output: generatedOutput, height: output.length }
218
+ }
219
+ // Keep a handle for tests/debugging; origGet unused beyond the guard.
220
+ void origGet
221
+ } catch { /* keep stock behavior */ }
222
+ })()
223
+ }
package/src/sse.ts CHANGED
@@ -15,8 +15,10 @@ import type { AnyEntry } from './types.js'
15
15
  export interface SseHandlers {
16
16
  onOpen?: () => void
17
17
  onSubscribed?: (session: any) => void
18
- onHistoryEntries?: (entries: AnyEntry[], done: boolean) => void
19
- onEntry?: (entry: AnyEntry) => void
18
+ /** ③ 新组元数据 (开轮卡本身随随后的 entries 事件到达). */
19
+ onGroupCreated?: (group: any) => void
20
+ /** ③ 组条目增量: version = 应用该批后的组版本 (调用方水位线判据). */
21
+ onEntries?: (payload: { group_id: string; group_id_version: number; entries: AnyEntry[] }) => void
20
22
  onTyping?: (active: boolean) => void
21
23
  onError?: (message: string, category?: string) => void
22
24
  onClose?: () => void
@@ -105,11 +107,15 @@ export class SseConnection {
105
107
  const ev = p?.event ?? eventName
106
108
  switch (ev) {
107
109
  case 'subscribed': this.handlers.onSubscribed?.(p.session); break
108
- case 'jsonl_history':
109
- this.handlers.onHistoryEntries?.(p.entries ?? [], !!p.done)
110
+ case 'group_created':
111
+ this.handlers.onGroupCreated?.(p.group)
110
112
  break
111
- case 'jsonl_entry':
112
- this.handlers.onEntry?.(p.entry)
113
+ case 'entries':
114
+ this.handlers.onEntries?.({
115
+ group_id: String(p.group_id ?? ''),
116
+ group_id_version: Number(p.group_id_version) || 0,
117
+ entries: Array.isArray(p.entries) ? p.entries : [],
118
+ })
113
119
  break
114
120
  case 'typing':
115
121
  this.handlers.onTyping?.(!!p.active)
@@ -119,7 +125,7 @@ export class SseConnection {
119
125
  this.handlers.onError?.(p.message ?? p.error ?? '未知错误', p.category)
120
126
  break
121
127
  default:
122
- // history / jsonl_meta / message / stream / etc. — currently unused by the TUI.
128
+ // history / message / stream / etc. — currently unused by the TUI.
123
129
  break
124
130
  }
125
131
  }
package/src/types.ts CHANGED
@@ -193,14 +193,23 @@ export interface ResourceAccess {
193
193
  // ════════════════════════════════════════════════════════════════════════════
194
194
  export type AnyEntry = Record<string, any>
195
195
 
196
+ // ── agent-history 组元数据 (协议 ① 的载荷) ───────────────────────────────────
197
+ export interface HistoryGroup {
198
+ id: string
199
+ seq: number
200
+ opener_ts: string | null
201
+ user_summary: string
202
+ version: number
203
+ entry_count: number
204
+ }
205
+
196
206
  // ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
197
207
  // Each SSE frame's data is a JSON object with an `event` discriminator.
198
208
  export type SseEvent =
199
209
  | { event: 'subscribed'; session: Session }
200
210
  | { event: 'history'; messages: any[]; total?: number }
201
- | { event: 'jsonl_meta'; session_id: string; total?: number; total_approximate?: number; tail_count?: number; jsonl_path?: string }
202
- | { event: 'jsonl_history'; reset?: boolean; done?: boolean; chunk_index?: number; count?: number; entries: AnyEntry[] }
203
- | { event: 'jsonl_entry'; session_id: string; entry: AnyEntry }
211
+ | { event: 'group_created'; session_id: string; group: HistoryGroup }
212
+ | { event: 'entries'; session_id: string; group_id: string; group_id_version: number; entries: AnyEntry[] }
204
213
  | { event: 'typing'; active: boolean }
205
214
  | { event: 'error'; message?: string; category?: string }
206
215
  | { event: 'server_error'; message?: string }
package/install.ps1 DELETED
@@ -1,265 +0,0 @@
1
- #requires -Version 5.1
2
- <#
3
- .SYNOPSIS
4
- Mobius TUI Windows 便携安装 (自带 portable Node, 无需 admin, 不依赖 npm.ps1)
5
- .DESCRIPTION
6
- 下载 portable Node win-x64 zip 解压到 ~/.mobius/node-portable/ → 用 node.exe 直跑 npm-cli.js
7
- 装 @mobius-os/mobius 到 ~/.mobius/npm-global/ → 自建 mobius.cmd 启动器(绝对路径指向便携 node)
8
- → 加 ~/.mobius/bin 到用户 PATH (无需 admin)。
9
- npm 安装和 mobius 启动均直接使用 node.exe/tsx;安装完成后同时注册两个
10
- Explorer 右键入口:“在 Mobius 中打开”(文件夹本身 + 文件夹空白处)。
11
- .EXAMPLE
12
- irm https://serve.nutshellai.cn/publish/auto/mobiustui/install-v15.ps1 | iex
13
- #>
14
- $ErrorActionPreference = 'Stop'
15
- $ProgressPreference = 'SilentlyContinue'
16
-
17
- function Step($m){ Write-Host "[*] $m" -ForegroundColor Cyan }
18
- function Ok($m) { Write-Host "[OK] $m" -ForegroundColor Green }
19
- function Warn($m){ Write-Host "[!] $m" -ForegroundColor Yellow }
20
- function Err($m) { Write-Host "[X] $m" -ForegroundColor Red }
21
- function Fail($m) { throw $m }
22
-
23
- function Invoke-MobiusInstall {
24
- Write-Host "=== Mobius TUI Windows 便携安装 (无需 admin) ===" -ForegroundColor White
25
-
26
- $MHOME = Join-Path $env:USERPROFILE ".mobius"
27
- $NODE_DIR = Join-Path $MHOME "node-portable"
28
- $GLOBAL_DIR = Join-Path $MHOME "npm-global"
29
- $NODE_VER = "v24.18.1"
30
- $ZIP_URL = "https://serve.nutshellai.cn/publish/auto/mobius-tui/node/node-$NODE_VER-win-x64.zip"
31
- $NODE_SHA256 = "ec56b84a7551893ab2324ebdfdc4ab974a63b4781162600b68a1293cc3e53765" # node v24.18.1 win-x64
32
- $nodeExe = Join-Path $NODE_DIR "node.exe"
33
- $npmCli = Join-Path $NODE_DIR "node_modules\npm\bin\npm-cli.js"
34
-
35
- New-Item -ItemType Directory -Force -Path $MHOME | Out-Null
36
-
37
- # --- 1. portable Node ---
38
- if (Test-Path $nodeExe) {
39
- Ok "便携 Node 已存在: $NODE_DIR"
40
- } else {
41
- Step "下载便携 Node $NODE_VER (~37MB), 可能需 1-2 分钟..."
42
- $zip = Join-Path $MHOME "node.zip"
43
- Invoke-WebRequest -Uri $ZIP_URL -OutFile $zip
44
- Step "校验 sha256..."
45
- $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
46
- if ($actual -ne $NODE_SHA256.ToLower()) { Fail "Node zip sha256 不匹配 (下载损坏? 期望 $NODE_SHA256 实际 $actual)" }
47
- Step "解压..."
48
- $tmp = Join-Path $MHOME "_extract"
49
- if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force }
50
- Expand-Archive -Path $zip -DestinationPath $tmp -Force
51
- $inner = Join-Path $tmp "node-$NODE_VER-win-x64"
52
- if (-not (Test-Path (Join-Path $inner "node.exe"))) { Fail "解压后未找到 node.exe" }
53
- if (Test-Path $NODE_DIR) { Remove-Item $NODE_DIR -Recurse -Force }
54
- Move-Item $inner $NODE_DIR -Force
55
- Remove-Item $tmp -Recurse -Force
56
- Remove-Item $zip -Force
57
- Ok "便携 Node 就绪: $NODE_DIR"
58
- }
59
-
60
- # --- 2. npm 装 @mobius-os/mobius (node.exe 直跑 npm-cli.js, 绕过 npm.ps1 的 ExecutionPolicy 拦截) ---
61
- Step "npm 安装 @mobius-os/mobius@latest (本地 install 到便携目录, 官方 registry)..."
62
- New-Item -ItemType Directory -Force -Path $GLOBAL_DIR | Out-Null
63
- $oldNodeModules = Join-Path $GLOBAL_DIR "node_modules"
64
- $oldPackageJson = Join-Path $GLOBAL_DIR "package.json"
65
- for ($attempt = 1; $attempt -le 3 -and (Test-Path $oldNodeModules); $attempt++) {
66
- Remove-Item $oldNodeModules -Recurse -Force -ErrorAction SilentlyContinue
67
- if (Test-Path $oldNodeModules) { Start-Sleep -Milliseconds (300 * $attempt) }
68
- }
69
- if (Test-Path $oldNodeModules) {
70
- Warn "请关闭正在运行的 mobius 终端后重试: $oldNodeModules"
71
- Fail "无法清理旧 node_modules,可能仍有 Mobius/Node 进程占用文件"
72
- }
73
- Remove-Item $oldPackageJson -Force -ErrorAction SilentlyContinue
74
- $npmLog = Join-Path $MHOME "npm-install.log"
75
- $npmStdout = Join-Path $MHOME "npm-install.stdout.log"
76
- $npmStderr = Join-Path $MHOME "npm-install.stderr.log"
77
- $previousPath = $env:Path
78
- # npm lifecycle scripts use cmd.exe and resolve `node` from PATH.
79
- $env:Path = "$NODE_DIR;$env:Path"
80
- Push-Location $GLOBAL_DIR
81
- $previousErrorActionPreference = $ErrorActionPreference
82
- $ErrorActionPreference = "Continue"
83
- try {
84
- # PowerShell 5.1 会把 npm 的 stderr warning 包装成 NativeCommandError。
85
- # 合并输出并只依据原生进程退出码判定成败,避免 warning 提前终止脚本。
86
- & $nodeExe $npmCli init -y 2>&1 | Out-Null
87
- $initExitCode = $LASTEXITCODE
88
- if ($initExitCode -ne 0) { throw "npm 初始化失败 (退出码 $initExitCode)" }
89
-
90
- # npm 11 默认拦截 esbuild postinstall,安装前明确允许该脚本。旧 npm 会忽略该字段。
91
- & $nodeExe $npmCli pkg set "allowScripts.esbuild=true" --json 2>&1 | Out-Null
92
- $configExitCode = $LASTEXITCODE
93
- if ($configExitCode -ne 0) { throw "配置 esbuild 安装脚本授权失败 (退出码 $configExitCode)" }
94
-
95
- function Invoke-NpmInstallAttempt([string]$registry, [int]$timeoutSeconds) {
96
- Remove-Item $npmStdout, $npmStderr -Force -ErrorAction SilentlyContinue
97
- # Do not call System.Diagnostics.Process instance methods here.
98
- # Windows PowerShell ConstrainedLanguage blocks those methods even
99
- # though Start-Process itself is allowed. A tiny cmd wrapper records
100
- # %ERRORLEVEL%; Wait-Process and taskkill remain CLM-safe.
101
- $attemptCmd = Join-Path $GLOBAL_DIR 'npm-install-attempt.cmd'
102
- $exitCodeFile = Join-Path $GLOBAL_DIR 'npm-install.exitcode'
103
- Remove-Item $exitCodeFile -Force -ErrorAction SilentlyContinue
104
- $attemptLines = @(
105
- '@echo off',
106
- 'setlocal',
107
- ('set "PATH={0};%PATH%"' -f $NODE_DIR),
108
- ('"{0}" "{1}" install "@mobius-os/mobius@latest" --registry "{2}" --loglevel warn >"{3}" 2>"{4}"' -f $nodeExe, $npmCli, $registry, $npmStdout, $npmStderr),
109
- 'set "EXIT_CODE=%ERRORLEVEL%"',
110
- ('>"{0}" echo %EXIT_CODE%' -f $exitCodeFile),
111
- 'exit /b %EXIT_CODE%'
112
- )
113
- Set-Content -Path $attemptCmd -Value $attemptLines -Encoding ASCII
114
- $proc = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/s', '/c', ('"{0}"' -f $attemptCmd)) `
115
- -WorkingDirectory $GLOBAL_DIR -PassThru -WindowStyle Hidden
116
- Wait-Process -Id $proc.Id -Timeout $timeoutSeconds -ErrorAction SilentlyContinue | Out-Null
117
- # Allow cmd a short moment to flush the marker after it exits, without
118
- # invoking any restricted Process instance methods.
119
- for ($markerAttempt = 1; $markerAttempt -le 20 -and -not (Test-Path $exitCodeFile); $markerAttempt++) {
120
- Start-Sleep -Milliseconds 100
121
- }
122
- if (-not (Test-Path $exitCodeFile)) {
123
- Warn "npm 官方源安装超过 $timeoutSeconds 秒,正在终止并切换镜像源..."
124
- & $env:ComSpec /d /s /c "taskkill /PID $($proc.Id) /T /F" 2>&1 | Out-Null
125
- Start-Sleep -Milliseconds 300
126
- Remove-Item $attemptCmd -Force -ErrorAction SilentlyContinue
127
- return @{ TimedOut = $true; ExitCode = $null }
128
- }
129
- $exitText = Get-Content -Path $exitCodeFile -Raw -ErrorAction SilentlyContinue
130
- $exitCode = $exitText -as [int]
131
- if ($null -eq $exitCode) { $exitCode = 1 }
132
- Remove-Item $attemptCmd, $exitCodeFile -Force -ErrorAction SilentlyContinue
133
- return @{ TimedOut = $false; ExitCode = $exitCode }
134
- }
135
-
136
- function Save-NpmAttemptLog([string]$label) {
137
- Add-Content -Path $npmLog -Value "`n===== $label ====="
138
- if (Test-Path $npmStdout) { Get-Content $npmStdout | Add-Content -Path $npmLog }
139
- if (Test-Path $npmStderr) { Get-Content $npmStderr | Add-Content -Path $npmLog }
140
- }
141
-
142
- Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
143
- $official = Invoke-NpmInstallAttempt "https://registry.npmjs.org/" 10
144
- Save-NpmAttemptLog "official registry.npmjs.org"
145
- $npmSucceeded = (-not $official.TimedOut -and $official.ExitCode -eq 0)
146
- if (-not $npmSucceeded) {
147
- if ($official.TimedOut) {
148
- Warn "官方源超时,切换 npmmirror 镜像源重试..."
149
- } else {
150
- Warn "官方源安装失败 (退出码 $($official.ExitCode)),切换 npmmirror 镜像源重试..."
151
- }
152
- # A timed-out npm process can leave a partial tree behind; remove only
153
- # the package tree and keep the user-level install directory intact.
154
- if (Test-Path $oldNodeModules) { Remove-Item $oldNodeModules -Recurse -Force -ErrorAction SilentlyContinue }
155
- $mirror = Invoke-NpmInstallAttempt "https://registry.npmmirror.com/" 120
156
- Save-NpmAttemptLog "mirror registry.npmmirror.com"
157
- $npmSucceeded = (-not $mirror.TimedOut -and $mirror.ExitCode -eq 0)
158
- }
159
- if (-not $npmSucceeded) {
160
- Err "npm 安装失败"
161
- if (Test-Path $npmLog) {
162
- Write-Host "--- npm 最近日志: $npmLog ---" -ForegroundColor Yellow
163
- Get-Content $npmLog -Tail 120
164
- Write-Host "--- npm 日志结束 ---" -ForegroundColor Yellow
165
- }
166
- Fail "npm 安装失败,完整日志: $npmLog"
167
- }
168
- } finally {
169
- $ErrorActionPreference = $previousErrorActionPreference
170
- $env:Path = $previousPath
171
- Pop-Location
172
- }
173
- $tsxCli = Join-Path $GLOBAL_DIR "node_modules\tsx\dist\cli.mjs"
174
- $esbuildExe = Join-Path $GLOBAL_DIR "node_modules\@esbuild\win32-x64\esbuild.exe"
175
- if (-not (Test-Path $tsxCli)) { Fail "npm 包缺少 tsx 运行时依赖,日志: $npmLog" }
176
- if (-not (Test-Path $esbuildExe)) { Fail "esbuild Windows 二进制未正确安装,日志: $npmLog" }
177
- Ok "mobius 装到: $GLOBAL_DIR"
178
-
179
- # --- 3. mobius 启动器 (.cmd batch, 用绝对路径指向便携 node, 不依赖系统 PATH/ExecutionPolicy) ---
180
- $binDir = Join-Path $MHOME "bin"
181
- $entryJs = Join-Path $GLOBAL_DIR "node_modules\@mobius-os\mobius\bin\mobius-tui.js"
182
- if (-not (Test-Path $entryJs)) { Fail "未找到 mobius 入口: $entryJs" }
183
- New-Item -ItemType Directory -Force -Path $binDir | Out-Null
184
- $mainTsx = Join-Path $GLOBAL_DIR "node_modules\@mobius-os\mobius\src\main.tsx"
185
- $mobiusCmd = Join-Path $binDir "mobius.cmd"
186
- @"
187
- @echo off
188
- "$nodeExe" "$tsxCli" "$mainTsx" %*
189
- "@ | Set-Content -Path $mobiusCmd -Encoding ASCII
190
- Ok "启动器: $mobiusCmd"
191
-
192
- # --- 4. 用户 PATH (无需 admin) ---
193
- $userPath = (Get-ItemProperty -Path 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue).Path
194
- if ($userPath -notlike "*$binDir*") {
195
- $newPath = if ($userPath) { "$userPath;$binDir" } else { $binDir }
196
- if (Test-Path 'HKCU:\Environment') {
197
- Set-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath
198
- } else {
199
- New-Item -Path 'HKCU:\Environment' -Force | Out-Null
200
- New-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath -PropertyType ExpandString -Force | Out-Null
201
- }
202
- $env:Path += ";$binDir"
203
- Ok "已加入用户 PATH: $binDir (重开 PowerShell 生效)"
204
- } else {
205
- Ok "PATH 已含: $binDir"
206
- }
207
-
208
- # --- 5. Explorer 右键菜单 (当前用户,无需 admin) ----------------------------
209
- # 两个入口必须同时注册:
210
- # Directory\shell\Mobius = 右键文件夹本身,目标参数 %1
211
- # Directory\Background\shell\Mobius = 右键文件夹空白处,目标参数 %V
212
- # 使用 .cmd 辅助启动器,避免右键动作依赖 PowerShell ExecutionPolicy。
213
- $openHereCmd = Join-Path $binDir "mobius-open-here.cmd"
214
- $openHereLines = @(
215
- '@echo off',
216
- 'setlocal',
217
- 'set "MOBIUS_TARGET=%~1"',
218
- 'if not defined MOBIUS_TARGET set "MOBIUS_TARGET=%CD%"',
219
- 'cd /d "%MOBIUS_TARGET%"',
220
- 'call "%~dp0mobius.cmd"',
221
- 'endlocal'
222
- )
223
- Set-Content -Path $openHereCmd -Value $openHereLines -Encoding ASCII
224
-
225
- $classes = "HKCU:\Software\Classes"
226
- $folderMenu = Join-Path $classes "Directory\shell\Mobius"
227
- $folderCommand = Join-Path $folderMenu "command"
228
- $backgroundMenu = Join-Path $classes "Directory\Background\shell\Mobius"
229
- $backgroundCommand = Join-Path $backgroundMenu "command"
230
-
231
- foreach ($key in @($folderCommand, $backgroundCommand)) {
232
- New-Item -Path $key -Force | Out-Null
233
- }
234
-
235
- foreach ($menu in @($folderMenu, $backgroundMenu)) {
236
- Set-ItemProperty -Path $menu -Name "MUIVerb" -Value "在 Mobius 中打开"
237
- Set-ItemProperty -Path $menu -Name "Icon" -Value "$env:SystemRoot\System32\cmd.exe,0"
238
- }
239
-
240
- # Explorer command quoting: cmd /c ""helper.cmd" "%1"".
241
- $folderCommandValue = '"{0}" /d /s /c ""{1}" "%1""' -f $env:ComSpec, $openHereCmd
242
- $backgroundCommandValue = '"{0}" /d /s /c ""{1}" "%V""' -f $env:ComSpec, $openHereCmd
243
- Set-Item -Path $folderCommand -Value $folderCommandValue
244
- Set-Item -Path $backgroundCommand -Value $backgroundCommandValue
245
- Ok "右键菜单已添加: 在 Mobius 中打开 (文件夹 + 空白处)"
246
-
247
- Write-Host ""
248
- Write-Host "=== 完成! 重开 PowerShell 后运行: mobius ===" -ForegroundColor Green
249
- Write-Host "(或直接运行: $mobiusCmd)" -ForegroundColor Gray
250
- Write-Host "右键文件夹或文件夹空白处,可选择: 在 Mobius 中打开" -ForegroundColor Gray
251
- }
252
-
253
- try {
254
- Invoke-MobiusInstall
255
- } catch {
256
- $errorLog = Join-Path $env:TEMP "mobius-install-v15-error.log"
257
- $errorText = $_ | Format-List * -Force | Out-String
258
- $errorText | Set-Content -Path $errorLog -Encoding UTF8
259
- Write-Host ""
260
- Err "Mobius 安装失败: $($_.Exception.Message)"
261
- Write-Host "完整错误日志: $errorLog" -ForegroundColor Yellow
262
- Write-Host $errorText -ForegroundColor DarkYellow
263
- try { Read-Host "按 Enter 返回 PowerShell(窗口不会自动关闭)" | Out-Null } catch { }
264
- return
265
- }