handmux 0.5.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/LICENSE +21 -0
- package/README.md +303 -0
- package/README.zh-CN.md +285 -0
- package/bin/handmux.js +417 -0
- package/hooks/handmux-notify.sh +20 -0
- package/hooks/handmux-write.cjs +92 -0
- package/package.json +52 -0
- package/public/assets/index-BN-IwtP6.css +32 -0
- package/public/assets/index-BUQ0R83h.js +157 -0
- package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/public/fonts/TWUnifont.woff2 +0 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/badge-96.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/logo.svg +32 -0
- package/public/index.html +105 -0
- package/public/manifest.webmanifest +37 -0
- package/public/offline.html +50 -0
- package/public/sw.js +117 -0
- package/src/.gitkeep +0 -0
- package/src/appName.js +23 -0
- package/src/asr/iflyConfig.js +10 -0
- package/src/asr/iflySign.js +16 -0
- package/src/auth.js +30 -0
- package/src/claudeEvents.js +212 -0
- package/src/cli/cfNamed.js +5 -0
- package/src/cli/claudeHooks.js +116 -0
- package/src/cli/cloudflareUrl.js +9 -0
- package/src/cli/cloudflared.js +53 -0
- package/src/cli/drivers.js +59 -0
- package/src/cli/options.js +169 -0
- package/src/cli/probe.js +16 -0
- package/src/cli/qr.js +34 -0
- package/src/cli/service.js +98 -0
- package/src/cli/setupWizard.js +248 -0
- package/src/cli/sshTunnel.js +12 -0
- package/src/cli/state.js +42 -0
- package/src/cli/supervisor.js +172 -0
- package/src/cli/tmuxConf.js +90 -0
- package/src/cli/tmuxVersion.js +49 -0
- package/src/cli/tunlite.js +22 -0
- package/src/config.js +6 -0
- package/src/docPath.js +46 -0
- package/src/docs.js +222 -0
- package/src/git.js +185 -0
- package/src/httpApi.js +546 -0
- package/src/previewServer.js +182 -0
- package/src/previews.js +118 -0
- package/src/push.js +121 -0
- package/src/server.js +97 -0
- package/src/staticCache.js +8 -0
- package/src/tmux/commands.js +223 -0
- package/src/trimCapture.js +28 -0
- package/src/uploadTypes.js +28 -0
- package/tmux/README.md +77 -0
- package/tmux/claude-tab-seed.py +67 -0
- package/tmux/claude-tab-seen.sh +14 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Opt-in wiring of the per-window Claude status dot into ~/.tmux.conf. The handmux hook writes a colour
|
|
2
|
+
// markup into each window's `@claude_dot` option on every Claude event (see hooks/handmux-write.cjs); tmux
|
|
3
|
+
// only RENDERS it if `window-status-format` references `#{@claude_dot}`. This module appends that display
|
|
4
|
+
// config PLUS the two enhancements (cold-start seed + focus-auto-clear), and — crucially — installs the
|
|
5
|
+
// scripts those enhancements need into a STABLE location so the wiring never breaks.
|
|
6
|
+
//
|
|
7
|
+
// Why a stable location: the scripts must be referenced by absolute path from ~/.tmux.conf. Pointing that
|
|
8
|
+
// at a repo checkout breaks the moment the repo moves or is renamed (the original failure: a stale
|
|
9
|
+
// `…/tmux-web/tmux/claude-tab-seen.sh` returned 127 on every window switch). So, exactly like the Claude
|
|
10
|
+
// hook copies its notify script into ~/.claude/hooks/, we copy the tmux scripts into ~/.handmux/tmux/ and
|
|
11
|
+
// the conf block references THAT — a path tied to $HOME, not to where handmux happens to be installed.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { pocketHome } from './state.js';
|
|
17
|
+
|
|
18
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PKG_TMUX = path.resolve(here, '../../tmux'); // server/tmux — shipped in the package ("files": [...,"tmux"])
|
|
20
|
+
const SCRIPTS = ['claude-tab-seed.py', 'claude-tab-seen.sh'];
|
|
21
|
+
|
|
22
|
+
const BEGIN = '# >>> handmux claude-dot >>>';
|
|
23
|
+
const END = '# <<< handmux claude-dot <<<';
|
|
24
|
+
|
|
25
|
+
export function tmuxConfPath(home = homedir()) { return path.join(home, '.tmux.conf'); }
|
|
26
|
+
|
|
27
|
+
// Where the seed/seen scripts are installed — stable across repo moves / renames / a global npm install.
|
|
28
|
+
export function tmuxScriptsDir(home = homedir()) { return path.join(pocketHome(home), 'tmux'); }
|
|
29
|
+
|
|
30
|
+
// The marked block we append. `status-style` resets the default green status bar (which would swallow the
|
|
31
|
+
// green "done" dot) to neutral grey; the window-status-format lines inject `#{@claude_dot}` before the
|
|
32
|
+
// window name; the seed paints existing windows on (re)load; the two hooks clear a "done" dot when you
|
|
33
|
+
// actually focus that window. All script references use the stable ~/.handmux/tmux path (see top comment).
|
|
34
|
+
export function dotBlock(home = homedir()) {
|
|
35
|
+
const dir = tmuxScriptsDir(home);
|
|
36
|
+
const seed = path.join(dir, 'claude-tab-seed.py');
|
|
37
|
+
const seen = path.join(dir, 'claude-tab-seen.sh');
|
|
38
|
+
return [
|
|
39
|
+
BEGIN,
|
|
40
|
+
'# Per-window Claude status dot (live via the handmux hook) + cold-start seed + focus-auto-clear.',
|
|
41
|
+
`# Scripts live in ${dir} (installed by handmux). Delete this whole block to disable.`,
|
|
42
|
+
"set -g status-style 'bg=colour236,fg=colour250'",
|
|
43
|
+
"set -g window-status-current-style 'bg=colour248,fg=colour234,bold'",
|
|
44
|
+
"set -g window-status-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'",
|
|
45
|
+
"set -g window-status-current-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'",
|
|
46
|
+
'set -g focus-events on',
|
|
47
|
+
`run-shell -b '${seed}'`,
|
|
48
|
+
`set-hook -g after-select-window 'run-shell -b "${seen} #{window_id}"'`,
|
|
49
|
+
`set-hook -g pane-focus-in 'run-shell -b "${seen} #{window_id}"'`,
|
|
50
|
+
END,
|
|
51
|
+
'',
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pure: does this ~/.tmux.conf text already wire the dot? Keys on `@claude_dot` so a user who hand-rolled
|
|
56
|
+
// their own is recognised as configured and never nagged or double-installed.
|
|
57
|
+
export function dotConfigured(text) {
|
|
58
|
+
return typeof text === 'string' && text.includes('@claude_dot');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readConf(home) {
|
|
62
|
+
try { return fs.readFileSync(tmuxConfPath(home), 'utf8'); } catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 'present' if the dot is already wired, else 'absent'. A missing ~/.tmux.conf is 'absent'.
|
|
66
|
+
export function tmuxDotStatus(home = homedir()) {
|
|
67
|
+
return dotConfigured(readConf(home) || '') ? 'present' : 'absent';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Copy the seed/seen scripts into the stable ~/.handmux/tmux dir (executable). Idempotent overwrite, so a
|
|
71
|
+
// reinstall after an upgrade refreshes them. Returns the dir. srcDir defaults to the shipped server/tmux.
|
|
72
|
+
export function installTmuxScripts(home = homedir(), srcDir = PKG_TMUX) {
|
|
73
|
+
const dir = tmuxScriptsDir(home);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
for (const f of SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(dir, f));
|
|
76
|
+
for (const f of SCRIPTS) { try { fs.chmodSync(path.join(dir, f), 0o755); } catch { /* best effort */ } }
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Install the scripts to ~/.handmux/tmux and append the marked block to ~/.tmux.conf (creating it if
|
|
81
|
+
// absent), idempotently. Returns { status }: 'present' if already wired (no-op), 'installed' if just added.
|
|
82
|
+
// Never touches the user's own lines — appends after them, separated by a newline.
|
|
83
|
+
export function installTmuxDot(home = homedir(), { srcDir = PKG_TMUX } = {}) {
|
|
84
|
+
const existing = readConf(home);
|
|
85
|
+
if (dotConfigured(existing || '')) return { status: 'present' };
|
|
86
|
+
installTmuxScripts(home, srcDir);
|
|
87
|
+
const prefix = existing && !existing.endsWith('\n') ? existing + '\n' : (existing || '');
|
|
88
|
+
fs.writeFileSync(tmuxConfPath(home), `${prefix}\n${dotBlock(home)}`);
|
|
89
|
+
return { status: 'installed' };
|
|
90
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// handmux's terminal rendering depends on `capture-pane -e -N` semantics, and those have drifted
|
|
2
|
+
// across tmux versions before (e.g. how -N pads trailing whitespace). So we check the host's tmux at
|
|
3
|
+
// start: absent → hard error; below the version we've validated → warn (don't block). exec injectable.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// Lowest tmux handmux has been validated against. Bump as the CI matrix (see release plan) widens.
|
|
7
|
+
export const MIN_TMUX = '3.0';
|
|
8
|
+
|
|
9
|
+
// "tmux 3.6a" / "tmux 3.4" / "tmux next-3.5" / "tmux openbsd-7.4" → {major,minor,suffix,raw}.
|
|
10
|
+
export function parseTmuxVersion(out) {
|
|
11
|
+
const m = /tmux\s+(?:next-|openbsd-)?(\d+)\.(\d+)([a-z]?)/i.exec(String(out || ''));
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return { major: +m[1], minor: +m[2], suffix: m[3] || '', raw: `${m[1]}.${m[2]}${m[3] || ''}` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// v >= min, comparing major.minor only (patch letters don't change the capture behaviour we rely on).
|
|
17
|
+
export function versionAtLeast(v, minStr = MIN_TMUX) {
|
|
18
|
+
if (!v) return false;
|
|
19
|
+
const [maj, min] = minStr.split('.').map(Number);
|
|
20
|
+
return v.major > maj || (v.major === maj && v.minor >= min);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function checkTmux(exec = spawnSync) {
|
|
24
|
+
const r = exec('tmux', ['-V'], { encoding: 'utf8' });
|
|
25
|
+
if (!r || r.status !== 0 || !r.stdout) return { present: false };
|
|
26
|
+
const version = parseTmuxVersion(r.stdout);
|
|
27
|
+
return { present: true, version, ok: versionAtLeast(version), raw: version ? version.raw : String(r.stdout).trim() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// In install-command order: probe for the package manager that's actually on this Linux box.
|
|
31
|
+
const LINUX_PKG_MANAGERS = [
|
|
32
|
+
['apt-get', 'sudo apt install tmux'],
|
|
33
|
+
['dnf', 'sudo dnf install tmux'],
|
|
34
|
+
['pacman', 'sudo pacman -S tmux'],
|
|
35
|
+
['zypper', 'sudo zypper install tmux'],
|
|
36
|
+
['apk', 'sudo apk add tmux'],
|
|
37
|
+
['yum', 'sudo yum install tmux'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// The exact "install tmux" command for THIS host, so a tmux-less newcomer gets a copy-paste line instead
|
|
41
|
+
// of a dead end. macOS → Homebrew; Linux → whichever package manager is present; Windows → tmux is a
|
|
42
|
+
// Unix tool, so point at WSL. exec/platform injectable for tests.
|
|
43
|
+
export function tmuxInstallHint(exec = spawnSync, platform = process.platform) {
|
|
44
|
+
if (platform === 'darwin') return 'brew install tmux';
|
|
45
|
+
if (platform === 'win32') return 'tmux is a Unix tool — install WSL (`wsl --install`), then inside it: sudo apt install tmux';
|
|
46
|
+
const has = (bin) => { const r = exec('which', [bin], { encoding: 'utf8' }); return !!r && r.status === 0; };
|
|
47
|
+
for (const [bin, cmd] of LINUX_PKG_MANAGERS) if (has(bin)) return cmd;
|
|
48
|
+
return 'install tmux with your package manager (e.g. `sudo apt install tmux`)';
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Resolve the tunlite binary (bundled dependency first → PATH) and probe passwordless SSH. tunlite is an
|
|
2
|
+
// npm dependency of handmux, so the bundled node_modules/.bin/tunlite means users need no separate install.
|
|
3
|
+
// `run`/`exists` injectable so the pure resolution logic unit-tests without spawning.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const BUNDLED = path.resolve(here, '../../node_modules/.bin/tunlite');
|
|
11
|
+
|
|
12
|
+
export function resolveTunlite({ run = spawnSync, exists = fs.existsSync, bundled = BUNDLED } = {}) {
|
|
13
|
+
const candidate = exists(bundled) ? bundled : 'tunlite';
|
|
14
|
+
const r = run(candidate, ['--version'], { encoding: 'utf8' });
|
|
15
|
+
if (r && r.status === 0) return candidate;
|
|
16
|
+
throw new Error('tunlite not found — install it (npm i -g tunlite / npx tunlite install)');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 0 = passwordless SSH ready; non-zero (tunlite exit 4 = needs-auth) means key setup required.
|
|
20
|
+
export function checkSshAuth(sshHost, { run = spawnSync, bin = 'tunlite' } = {}) {
|
|
21
|
+
return run(bin, ['check', sshHost], { stdio: 'ignore' }).status;
|
|
22
|
+
}
|
package/src/config.js
ADDED
package/src/docPath.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const EXT = {
|
|
2
|
+
'.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html',
|
|
3
|
+
// Plain-text files: rendered verbatim in a <pre> (no markdown parsing).
|
|
4
|
+
'.txt': 'text', '.log': 'text', '.sh': 'text',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// Map a filename to our renderable doc type by extension, or null. Case-insensitive.
|
|
8
|
+
export function docTypeFor(name) {
|
|
9
|
+
const m = /\.[A-Za-z0-9]+$/.exec(name || '');
|
|
10
|
+
return m ? (EXT[m[0].toLowerCase()] ?? null) : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Image extensions the in-app viewer can show inline via <img> (GIF animates natively). SVG is safe
|
|
14
|
+
// here because <img>-loaded SVG never runs its scripts. Returns 'image' or null. Case-insensitive.
|
|
15
|
+
const IMG_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']);
|
|
16
|
+
export function imageTypeFor(name) {
|
|
17
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
|
|
18
|
+
return m && IMG_EXT.has(m[1].toLowerCase()) ? 'image' : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// True if `child` equals `parent` or sits inside it. Both are expected to be realpaths.
|
|
22
|
+
// Guards the sibling-prefix trap: /home/ab is NOT under /home/a.
|
|
23
|
+
export function isUnder(child, parent) {
|
|
24
|
+
if (child === parent) return true;
|
|
25
|
+
const p = parent.endsWith('/') ? parent : parent + '/';
|
|
26
|
+
return child.startsWith(p);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Sanitize a client-supplied upload filename to a single safe path segment, or null if unsafe.
|
|
30
|
+
// Takes the basename (drops any dir part, handling both / and \), then rejects empty / '.' / '..'
|
|
31
|
+
// and dotfiles (no hidden files). The result never contains a path separator.
|
|
32
|
+
export function safeUploadName(raw) {
|
|
33
|
+
if (typeof raw !== 'string') return null;
|
|
34
|
+
const base = raw.split('/').pop().split('\\').pop();
|
|
35
|
+
if (!base || base === '.' || base === '..' || base[0] === '.') return null;
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// True if `real` (a realpath at/under `home`) has any path segment BELOW home that starts with '.'
|
|
40
|
+
// — i.e. it lives inside a hidden directory like ~/.ssh or ~/.config. `home` is the realpath of
|
|
41
|
+
// $HOME. The home root itself is not hidden.
|
|
42
|
+
export function hasHiddenSegment(real, home) {
|
|
43
|
+
if (real === home) return false;
|
|
44
|
+
const rel = real.startsWith(home.endsWith('/') ? home : home + '/') ? real.slice(home.length) : real;
|
|
45
|
+
return rel.split('/').filter(Boolean).some((seg) => seg.startsWith('.'));
|
|
46
|
+
}
|
package/src/docs.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { docTypeFor, imageTypeFor, isUnder, hasHiddenSegment } from './docPath.js';
|
|
5
|
+
|
|
6
|
+
const MAX_READ_BYTES = 2 * 1024 * 1024; // 2MB cap for in-app text reads (readDoc)
|
|
7
|
+
// Single source of truth for the 50MB transfer cap, shared by download (maxDownloadBytes default
|
|
8
|
+
// below) and upload (httpApi.js imports this as the maxUploadBytes default). Server-side only.
|
|
9
|
+
export const MAX_TRANSFER_BYTES = 50 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
// Flatten an absolute cwd into ONE filesystem-safe path segment for the per-project upload space,
|
|
12
|
+
// Claude-Code style: '/' → '-' (so /Users/x/proj → -Users-x-proj), and any other non-portable char
|
|
13
|
+
// → '_'. A valid cwd always starts with '/', so the key always starts with '-' — it can never be
|
|
14
|
+
// '..'/'.' and, having no separators, can never escape the uploads root. Empty/relative → '_default'.
|
|
15
|
+
export function encodeCwdKey(cwd) {
|
|
16
|
+
if (typeof cwd !== 'string' || cwd[0] !== '/') return '_default';
|
|
17
|
+
return cwd.replace(/\//g, '-').replace(/[^A-Za-z0-9._-]/g, '_') || '_default';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Factory bound to a `home` root. All reads pass through fs.realpath then isUnder(realHome):
|
|
21
|
+
// realpath collapses ../ and resolves symlinks, so the home-containment check is the final word —
|
|
22
|
+
// a symlink whose target escapes home resolves outside and gets rejected.
|
|
23
|
+
export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANSFER_BYTES } = {}) {
|
|
24
|
+
// $HOME is constant at runtime — resolve once at construction, reuse the same promise everywhere.
|
|
25
|
+
const realHomeP = fs.realpath(home);
|
|
26
|
+
// Browse / read / download / upload may also reach a few EXTRA roots OUTSIDE $HOME (e.g. /tmp,
|
|
27
|
+
// $TMPDIR) so transient files an agent drops there are reachable from the phone. Resolved once:
|
|
28
|
+
// realpath'd, deduped, missing ones skipped, and any extra already inside home dropped (home
|
|
29
|
+
// covers it). `home` is always roots[0]. Session-cwd (resolveCwd) and the upload stash stay
|
|
30
|
+
// home-only on purpose. The "under one of these roots" check replaces the old single isUnder(home).
|
|
31
|
+
const rootsP = (async () => {
|
|
32
|
+
const rh = await realHomeP;
|
|
33
|
+
const out = [rh];
|
|
34
|
+
for (const r of extraRoots) {
|
|
35
|
+
if (typeof r !== 'string' || !r) continue;
|
|
36
|
+
let real;
|
|
37
|
+
try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
|
|
38
|
+
if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
|
|
39
|
+
out.push(real);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
})();
|
|
43
|
+
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
44
|
+
const rootOf = (real, roots) => {
|
|
45
|
+
let best = null;
|
|
46
|
+
for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
|
|
47
|
+
return best;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
async function readDoc(rawPath) {
|
|
51
|
+
if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
52
|
+
const type = docTypeFor(rawPath);
|
|
53
|
+
if (!type) return { error: 'bad extension', status: 400 };
|
|
54
|
+
let real;
|
|
55
|
+
try { real = await fs.realpath(rawPath); }
|
|
56
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
57
|
+
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
58
|
+
let st;
|
|
59
|
+
try { st = await fs.stat(real); }
|
|
60
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
61
|
+
if (!st.isFile()) return { error: 'not a file', status: 400 };
|
|
62
|
+
if (st.size > MAX_READ_BYTES) return { error: 'too large', status: 413 };
|
|
63
|
+
const content = await fs.readFile(real, 'utf8');
|
|
64
|
+
return { name: basename(real), type, content };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function listDir(rawPath) {
|
|
68
|
+
const rh = await realHomeP;
|
|
69
|
+
const roots = await rootsP;
|
|
70
|
+
let real;
|
|
71
|
+
try { real = await fs.realpath(rawPath ? rawPath : home); }
|
|
72
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
73
|
+
if (!rootOf(real, roots)) return { error: 'outside home', status: 400 };
|
|
74
|
+
const st = await fs.stat(real);
|
|
75
|
+
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
76
|
+
const dirents = await fs.readdir(real, { withFileTypes: true });
|
|
77
|
+
// withFileTypes uses lstat semantics: d.isFile() is false for symlinks, so symlinks are
|
|
78
|
+
// intentionally NOT listed (security property). Every regular file is listed now (not just
|
|
79
|
+
// docs); the extension decides whether it opens in-app as a doc ('doc'), an image viewer
|
|
80
|
+
// ('image'), or can only be downloaded ('file').
|
|
81
|
+
const dirEntries = [];
|
|
82
|
+
const fileDirents = [];
|
|
83
|
+
for (const d of dirents) {
|
|
84
|
+
if (d.isDirectory()) dirEntries.push({ name: d.name, type: 'dir' });
|
|
85
|
+
else if (d.isFile()) fileDirents.push(d);
|
|
86
|
+
}
|
|
87
|
+
// stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
|
|
88
|
+
// (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
|
|
89
|
+
const fileEntries = await Promise.all(fileDirents.map(async (d) => {
|
|
90
|
+
const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
|
|
91
|
+
let size = 0;
|
|
92
|
+
try { size = (await fs.stat(join(real, d.name))).size; } catch { /* gone mid-list → size 0 */ }
|
|
93
|
+
return { name: d.name, type, size };
|
|
94
|
+
}));
|
|
95
|
+
const entries = [...dirEntries, ...fileEntries];
|
|
96
|
+
// dirs first; files (doc+file) interleaved alphabetically
|
|
97
|
+
entries.sort((a, b) =>
|
|
98
|
+
(a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
99
|
+
// "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
|
|
100
|
+
const parent = roots.includes(real) ? null : join(real, '..');
|
|
101
|
+
return { path: real, home: rh, roots, parent, entries };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
|
|
105
|
+
// readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
|
|
106
|
+
async function statForDownload(rawPath) {
|
|
107
|
+
if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
108
|
+
let real;
|
|
109
|
+
try { real = await fs.realpath(rawPath); }
|
|
110
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
111
|
+
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
112
|
+
let st;
|
|
113
|
+
try { st = await fs.stat(real); }
|
|
114
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
115
|
+
if (!st.isFile()) return { error: 'not a file', status: 400 };
|
|
116
|
+
if (st.size > maxDownloadBytes) return { error: 'too large', status: 413 };
|
|
117
|
+
return { real, name: basename(real), size: st.size };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve a path for UPLOAD TARGET: a directory under one of the allowed roots, not inside any
|
|
121
|
+
// hidden directory (relative to its own root). The $HOME root itself is off-limits (don't litter
|
|
122
|
+
// the home dir); an extra root like /tmp IS uploadable directly, since dropping files there is the
|
|
123
|
+
// whole point. Same realpath boundary as the rest.
|
|
124
|
+
async function resolveUploadDir(rawDir) {
|
|
125
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
126
|
+
const rh = await realHomeP;
|
|
127
|
+
let real;
|
|
128
|
+
try { real = await fs.realpath(rawDir); }
|
|
129
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
130
|
+
const root = rootOf(real, await rootsP);
|
|
131
|
+
if (!root) return { error: 'outside home', status: 400 };
|
|
132
|
+
if (real === rh) return { error: 'home root not allowed', status: 400 };
|
|
133
|
+
if (hasHiddenSegment(real, root)) return { error: 'hidden directory not allowed', status: 400 };
|
|
134
|
+
let st;
|
|
135
|
+
try { st = await fs.stat(real); }
|
|
136
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
137
|
+
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
138
|
+
return { real };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve the STASH upload target: a per-project folder under ~/.handmux/uploads, created on
|
|
142
|
+
// demand. Deliberately OUTSIDE the user's project trees (so uploads can never be `git add`-ed by
|
|
143
|
+
// accident) yet still under $HOME, so the absolute path the chat box pastes stays readable by an
|
|
144
|
+
// agent running anywhere. Mirrors Claude Code's per-project layout: the caller's cwd is flattened
|
|
145
|
+
// ('/'→'-') into a single path segment (see encodeCwdKey), so each working directory gets its own
|
|
146
|
+
// space; an unknown cwd falls into `_default`. Returns the realpath'd dir.
|
|
147
|
+
async function resolveStashDir(rawCwd) {
|
|
148
|
+
const rh = await realHomeP;
|
|
149
|
+
const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
|
|
150
|
+
try { await fs.mkdir(target, { recursive: true }); }
|
|
151
|
+
catch { return { error: 'mkdir failed', status: 500 }; }
|
|
152
|
+
let real;
|
|
153
|
+
try { real = await fs.realpath(target); } // re-resolve in case a segment is a symlink
|
|
154
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
155
|
+
if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
|
|
156
|
+
return { real };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve a path for use as a NEW session/window CWD: any directory under home (home root IS
|
|
160
|
+
// allowed, and hidden dirs ARE allowed — the browser can navigate into them). Same realpath +
|
|
161
|
+
// isUnder boundary as the rest; symlinks escaping home resolve outside → rejected.
|
|
162
|
+
async function resolveCwd(rawDir) {
|
|
163
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
164
|
+
const rh = await realHomeP;
|
|
165
|
+
let real;
|
|
166
|
+
try { real = await fs.realpath(rawDir); }
|
|
167
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
168
|
+
if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
|
|
169
|
+
let st;
|
|
170
|
+
try { st = await fs.stat(real); }
|
|
171
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
172
|
+
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
173
|
+
return { real };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Resolve a directory for BROWSE-side ops (the mkdir target): a directory under ANY allowed root
|
|
177
|
+
// (the root itself + hidden dirs allowed, like resolveCwd but multi-root). resolveCwd stays
|
|
178
|
+
// home-only — a new session's cwd should never land in a temp root by accident.
|
|
179
|
+
async function resolveBrowseDir(rawDir) {
|
|
180
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
181
|
+
let real;
|
|
182
|
+
try { real = await fs.realpath(rawDir); }
|
|
183
|
+
catch { return { error: 'not found', status: 404 }; }
|
|
184
|
+
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
185
|
+
let st;
|
|
186
|
+
try { st = await fs.stat(real); }
|
|
187
|
+
catch { return { error: 'not accessible', status: 404 }; }
|
|
188
|
+
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
189
|
+
return { real };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Create a new directory `name` inside `parentRaw`. The parent must be a directory under one of
|
|
193
|
+
// the allowed roots (root + hidden dirs allowed). `name` must be a single safe path segment.
|
|
194
|
+
async function makeDir(parentRaw, name) {
|
|
195
|
+
const nm = typeof name === 'string' ? name.trim() : '';
|
|
196
|
+
if (!nm || nm === '.' || nm === '..' || nm.includes('/') || nm.includes('\\') || nm.includes('\0')) {
|
|
197
|
+
return { error: 'bad name', status: 400 };
|
|
198
|
+
}
|
|
199
|
+
const parent = await resolveBrowseDir(parentRaw); // realpath + under-a-root + isDirectory
|
|
200
|
+
if (parent.error) return parent;
|
|
201
|
+
const target = join(parent.real, nm);
|
|
202
|
+
try {
|
|
203
|
+
await fs.mkdir(target);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
if (e.code === 'EEXIST') return { error: 'exists', status: 409 };
|
|
206
|
+
return { error: 'mkdir failed', status: 500 };
|
|
207
|
+
}
|
|
208
|
+
return { real: target };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { readDoc, listDir, statForDownload, resolveUploadDir, resolveStashDir, resolveCwd, makeDir };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The extra (outside-$HOME) roots the file browser may reach: the system temp dir and, if set, the
|
|
215
|
+
// per-user $TMPDIR (on macOS that's /var/folders/.../T). Missing ones are skipped at resolve time.
|
|
216
|
+
export function defaultExtraRoots(env = process.env) {
|
|
217
|
+
const roots = ['/tmp'];
|
|
218
|
+
if (env.TMPDIR) roots.push(env.TMPDIR);
|
|
219
|
+
return roots;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const defaultDocs = createDocs({ home: homedir(), extraRoots: defaultExtraRoots() });
|
package/src/git.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename, isAbsolute } from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { isUnder } from './docPath.js';
|
|
6
|
+
|
|
7
|
+
// 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
|
|
8
|
+
const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
|
|
9
|
+
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export function createGit({ home } = {}) {
|
|
12
|
+
const realHomeP = fs.realpath(home);
|
|
13
|
+
|
|
14
|
+
function git(cwd, args) {
|
|
15
|
+
const sub = args[0];
|
|
16
|
+
if (!READONLY.has(sub)) return Promise.reject(new Error(`blocked subcommand: ${sub}`));
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
execFile('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], { maxBuffer: MAX_BUFFER }, (err, stdout) => {
|
|
19
|
+
if (err) reject(err); else resolve(stdout);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function resolveRepo(rawPath) {
|
|
25
|
+
if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
|
|
26
|
+
const rh = await realHomeP;
|
|
27
|
+
let real;
|
|
28
|
+
try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
|
|
29
|
+
if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
|
|
30
|
+
return { real };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function isRepo(dir) {
|
|
34
|
+
try { return (await git(dir, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true'; }
|
|
35
|
+
catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `realDir` is used for git operations; `displayPath` is what we expose (the caller's original path,
|
|
39
|
+
// avoiding macOS /private/var vs /var confusion when the caller passed a non-realpath'd path).
|
|
40
|
+
async function repoMeta(realDir, displayPath) {
|
|
41
|
+
const p = displayPath ?? realDir;
|
|
42
|
+
let branch = 'HEAD';
|
|
43
|
+
try { branch = (await git(realDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); } catch { /* empty repo */ }
|
|
44
|
+
let dirty = false;
|
|
45
|
+
try { dirty = (await git(realDir, ['status', '--porcelain'])).trim().length > 0; } catch { /* ignore */ }
|
|
46
|
+
return { name: basename(p), path: p, branch, dirty };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function detectRepos(rawDir) {
|
|
50
|
+
const r = await resolveRepo(rawDir);
|
|
51
|
+
if (r.error) return r;
|
|
52
|
+
if (await isRepo(r.real)) return { repos: [await repoMeta(r.real, rawDir)] };
|
|
53
|
+
const repos = [];
|
|
54
|
+
let dirents = [];
|
|
55
|
+
try { dirents = await fs.readdir(r.real, { withFileTypes: true }); } catch { /* ignore */ }
|
|
56
|
+
for (const d of dirents) {
|
|
57
|
+
if (!d.isDirectory()) continue;
|
|
58
|
+
const child = join(r.real, d.name);
|
|
59
|
+
const childDisplay = join(rawDir, d.name);
|
|
60
|
+
if (await isRepo(child)) repos.push(await repoMeta(child, childDisplay));
|
|
61
|
+
}
|
|
62
|
+
repos.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
return { repos };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function status(rawRepo) {
|
|
67
|
+
const r = await resolveRepo(rawRepo);
|
|
68
|
+
if (r.error) return r;
|
|
69
|
+
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
70
|
+
let out;
|
|
71
|
+
try { out = await git(r.real, ['status', '--porcelain']); }
|
|
72
|
+
catch { return { error: 'git error', status: 500 }; }
|
|
73
|
+
const changes = out.split('\n').filter(Boolean).map((line) => {
|
|
74
|
+
const x = line[0], y = line[1];
|
|
75
|
+
let path = line.slice(3);
|
|
76
|
+
const arrow = path.indexOf(' -> ');
|
|
77
|
+
if (arrow >= 0) path = path.slice(arrow + 4);
|
|
78
|
+
return { x: x === ' ' ? '' : x, y: y === ' ' ? '' : y, path };
|
|
79
|
+
});
|
|
80
|
+
return { changes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SEP = '\x1f';
|
|
84
|
+
|
|
85
|
+
// 分支 / ref 名校验:挡选项注入(开头 '-')、路径穿越('..')、NUL,并限定到安全字符子集。
|
|
86
|
+
// 只读 git log 用,失败时 git 自己会再拒一次。
|
|
87
|
+
function safeRef(ref) {
|
|
88
|
+
if (typeof ref !== 'string' || !ref) return null;
|
|
89
|
+
if (ref[0] === '-' || ref.includes('..') || ref.includes('\0')) return null;
|
|
90
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) return null;
|
|
91
|
+
return ref;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function log(rawRepo, limit = 50, ref) {
|
|
95
|
+
const r = await resolveRepo(rawRepo);
|
|
96
|
+
if (r.error) return r;
|
|
97
|
+
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
98
|
+
const n = Math.max(1, Math.min(500, Number(limit) || 50));
|
|
99
|
+
const safeR = ref == null || ref === '' ? null : safeRef(ref);
|
|
100
|
+
if (ref && !safeR) return { error: 'bad ref', status: 400 };
|
|
101
|
+
const args = ['log', `-n${n}`, `--pretty=format:%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`];
|
|
102
|
+
if (safeR) args.push(safeR); // git log <ref> …(指定分支只读看历史,不动工作树)
|
|
103
|
+
let out = '';
|
|
104
|
+
try { out = await git(r.real, args); }
|
|
105
|
+
catch { return { commits: [] }; } // 空仓库(无提交)/ 无此 ref → 空列表
|
|
106
|
+
const commits = out.split('\n').filter(Boolean).map((line) => {
|
|
107
|
+
const [hash, short, subject, author, relDate] = line.split(SEP);
|
|
108
|
+
return { hash, short, subject, author, relDate };
|
|
109
|
+
});
|
|
110
|
+
return { commits };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function branches(rawRepo) {
|
|
114
|
+
const r = await resolveRepo(rawRepo);
|
|
115
|
+
if (r.error) return r;
|
|
116
|
+
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
117
|
+
const fmt = ['%(refname:short)', '%(HEAD)', '%(upstream:short)', '%(upstream:track)'].join(SEP);
|
|
118
|
+
let out;
|
|
119
|
+
try { out = await git(r.real, ['for-each-ref', `--format=${fmt}`, 'refs/heads']); }
|
|
120
|
+
catch { return { branches: [] }; }
|
|
121
|
+
const branches = out.split('\n').filter(Boolean).map((line) => {
|
|
122
|
+
const [name, head, upstream, track] = line.split(SEP);
|
|
123
|
+
const ahead = Number((track.match(/ahead (\d+)/) || [])[1] || 0);
|
|
124
|
+
const behind = Number((track.match(/behind (\d+)/) || [])[1] || 0);
|
|
125
|
+
return { name, current: head === '*', upstream: upstream || null, ahead, behind };
|
|
126
|
+
});
|
|
127
|
+
return { branches };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 文件路径校验:相对、非绝对、不以 '-' 开头(防选项注入)、无 '..' 段、无 NUL。
|
|
131
|
+
function safeRelPath(p) {
|
|
132
|
+
if (typeof p !== 'string' || !p || p[0] === '-' || isAbsolute(p)) return null;
|
|
133
|
+
if (p.includes('\0') || p.split('/').some((seg) => seg === '..')) return null;
|
|
134
|
+
return p;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const MAX_DIFF_BYTES = 512 * 1024;
|
|
138
|
+
function cap(text) {
|
|
139
|
+
if (text.length <= MAX_DIFF_BYTES) return { diff: text, truncated: false };
|
|
140
|
+
return { diff: text.slice(0, MAX_DIFF_BYTES), truncated: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// diff 语义:仅 path → 工作区 vs HEAD;staged → 暂存区 vs HEAD;commit → 该提交 vs 其父。
|
|
144
|
+
async function diff(rawRepo, { path, commit, staged } = {}) {
|
|
145
|
+
const r = await resolveRepo(rawRepo);
|
|
146
|
+
if (r.error) return r;
|
|
147
|
+
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
148
|
+
const rel = safeRelPath(path);
|
|
149
|
+
if (!rel) return { error: 'bad path', status: 400 };
|
|
150
|
+
let args;
|
|
151
|
+
if (commit) {
|
|
152
|
+
if (!/^[0-9a-fA-F]{4,40}$/.test(commit)) return { error: 'bad commit', status: 400 };
|
|
153
|
+
args = ['show', '--format=', commit, '--', rel];
|
|
154
|
+
} else if (staged) {
|
|
155
|
+
args = ['diff', '--staged', '--', rel];
|
|
156
|
+
} else {
|
|
157
|
+
args = ['diff', 'HEAD', '--', rel];
|
|
158
|
+
}
|
|
159
|
+
let out = '';
|
|
160
|
+
try { out = await git(r.real, args); } catch (e) { return { error: 'diff failed', status: 500 }; }
|
|
161
|
+
return cap(out);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function commit(rawRepo, hash) {
|
|
165
|
+
const r = await resolveRepo(rawRepo);
|
|
166
|
+
if (r.error) return r;
|
|
167
|
+
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
168
|
+
if (!/^[0-9a-fA-F]{4,40}$/.test(hash)) return { error: 'bad commit', status: 400 };
|
|
169
|
+
let message, ns;
|
|
170
|
+
try {
|
|
171
|
+
message = (await git(r.real, ['show', '-s', '--format=%B', hash])).trim();
|
|
172
|
+
// --root 让首次提交(无父)也能列出文件。
|
|
173
|
+
ns = await git(r.real, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
|
|
174
|
+
} catch { return { error: 'git error', status: 500 }; }
|
|
175
|
+
const files = ns.split('\n').filter(Boolean).map((line) => {
|
|
176
|
+
const [code, ...rest] = line.split('\t');
|
|
177
|
+
return { x: code[0], y: '', path: rest[rest.length - 1] };
|
|
178
|
+
});
|
|
179
|
+
return { message, files };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const defaultGit = createGit({ home: homedir() });
|