handmux 0.23.0 → 0.24.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 +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/handmux.js +3 -689
- package/dist/bin/handmux.js +850 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
- package/dist/package.json +126 -0
- package/dist/public/assets/index-CicdY9E9.css +32 -0
- package/dist/public/assets/index-D4VP5t8h.js +337 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/{src → dist/src}/agents/claude.js +105 -83
- package/dist/src/agents/codex.js +217 -0
- package/{src → dist/src}/agents/index.js +7 -7
- package/dist/src/agents/processIdentity.js +80 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiErrors.js +77 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +499 -0
- package/{src → dist/src}/cli/claudeHooks.js +146 -108
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +77 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +36 -0
- package/dist/src/cli/i18n/en.js +366 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +365 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +144 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +114 -0
- package/{src → dist/src}/cli/statusLine.js +63 -46
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +2299 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +96 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +724 -0
- package/dist/src/codexUsageSnapshot.js +205 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +272 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +68 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +157 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +89 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/codex.js +606 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +78 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +232 -0
- package/dist/src/routes/system.js +156 -0
- package/dist/src/routes/terminal.js +238 -0
- package/dist/src/routes/transcript.js +197 -0
- package/dist/src/routes/workspace.js +267 -0
- package/{src → dist/src}/server.js +100 -74
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +156 -171
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +240 -0
- package/dist/src/transcriptReader.js +113 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +29 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usage.js +260 -0
- package/dist/src/workspace/agentRunner.js +204 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +314 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +694 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +27 -15
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/codex.js +0 -168
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// File browsing under $HOME: read a doc, list/create a dir, download any file, and upload. The docs data
|
|
2
|
+
// layer realpaths + enforces the under-$HOME containment; these routes only map {error,status} to HTTP
|
|
3
|
+
// and own the multipart upload plumbing. Mounted under /api by createApiRouter.
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import busboy from 'busboy';
|
|
6
|
+
import { promises as fsp, createWriteStream } from 'node:fs';
|
|
7
|
+
import { join as joinPath } from 'node:path';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { safeUploadName } from '../docPath.js';
|
|
10
|
+
import { isAllowedUploadExt } from '../uploadTypes.js';
|
|
11
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
function errorCode(error) {
|
|
13
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
14
|
+
return undefined;
|
|
15
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
|
|
18
|
+
const r = express.Router();
|
|
19
|
+
// Read a single text file under $HOME. The docs layer realpaths, enforces containment and rejects
|
|
20
|
+
// binary content; known Markdown/HTML extensions keep their richer renderer.
|
|
21
|
+
// the route only maps its {error,status} to an HTTP status.
|
|
22
|
+
r.get('/file', async (req, res, next) => {
|
|
23
|
+
try {
|
|
24
|
+
// Conditional read: `?mtime=<ms>` (the client's last-known mtime) lets the server answer
|
|
25
|
+
// `{ notModified: true }` without re-reading/re-sending an unchanged doc.
|
|
26
|
+
const m = Number(req.query.mtime);
|
|
27
|
+
const knownMtime = Number.isFinite(m) ? m : null;
|
|
28
|
+
const out = await docs.readDoc(typeof req.query.path === 'string' ? req.query.path : '', knownMtime);
|
|
29
|
+
if ('error' in out)
|
|
30
|
+
return res.status(out.status).json({ error: out.error });
|
|
31
|
+
if (out.notModified)
|
|
32
|
+
return res.json({ name: out.name, type: out.type, mtimeMs: out.mtimeMs, notModified: true });
|
|
33
|
+
return res.json({ name: out.name, type: out.type, content: out.content, mtimeMs: out.mtimeMs });
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return next(e);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// List a directory under $HOME (empty path → $HOME). Only subdirs + md/html files are returned.
|
|
40
|
+
r.get('/dir', async (req, res, next) => {
|
|
41
|
+
try {
|
|
42
|
+
const out = await docs.listDir(typeof req.query.path === 'string' ? req.query.path : '');
|
|
43
|
+
if ('error' in out)
|
|
44
|
+
return res.status(out.status).json({ error: out.error });
|
|
45
|
+
return res.json(out);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return next(e);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Create a directory `name` inside `dir` (both must be under $HOME). docs.makeDir enforces the
|
|
52
|
+
// boundary check and validates the name; the route maps {error,status} to an HTTP response.
|
|
53
|
+
r.post('/dir', async (req, res, next) => {
|
|
54
|
+
const body = req.body;
|
|
55
|
+
const dir = isRecord(body) ? body.dir : undefined;
|
|
56
|
+
const name = isRecord(body) ? body.name : undefined;
|
|
57
|
+
if (typeof dir !== 'string' || typeof name !== 'string')
|
|
58
|
+
return res.status(400).json({ error: 'bad request' });
|
|
59
|
+
try {
|
|
60
|
+
const out = await docs.makeDir(dir, name);
|
|
61
|
+
if ('error' in out)
|
|
62
|
+
return res.status(out.status).json({ error: out.error });
|
|
63
|
+
return res.status(201).json({ path: out.real });
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return next(e);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// Download ANY regular file under $HOME (no extension white-list). docs.statForDownload enforces
|
|
70
|
+
// the realpath+isUnder boundary and the 50MB cap; res.download streams it and forces
|
|
71
|
+
// Content-Disposition: attachment (so HTML/SVG can never render inline → no stored-XSS via open).
|
|
72
|
+
r.get('/download', async (req, res, next) => {
|
|
73
|
+
try {
|
|
74
|
+
const out = await docs.statForDownload(typeof req.query.path === 'string' ? req.query.path : '');
|
|
75
|
+
if ('error' in out)
|
|
76
|
+
return res.status(out.status).json({ error: out.error });
|
|
77
|
+
// Conditional download for the inline image viewer: `?mtime=<ms>` (the client's last-known mtime)
|
|
78
|
+
// → 304 when unchanged, so a large image isn't re-streamed just to re-view it. `X-Mtime` on the
|
|
79
|
+
// 200 lets the client store the current mtime for the next check. Plain downloads pass no mtime.
|
|
80
|
+
const m = Number(req.query.mtime);
|
|
81
|
+
if (Number.isFinite(m) && m === out.mtimeMs)
|
|
82
|
+
return res.status(304).end();
|
|
83
|
+
res.setHeader('X-Mtime', String(out.mtimeMs));
|
|
84
|
+
return res.download(out.real, out.name, (err) => { if (err && !res.headersSent)
|
|
85
|
+
next(err); });
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return next(e);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
|
|
92
|
+
// (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
|
|
93
|
+
async function freeUploadName(dir, name) {
|
|
94
|
+
const dot = name.lastIndexOf('.');
|
|
95
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
96
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
97
|
+
let cand = name;
|
|
98
|
+
for (let n = 1; n <= 999; n++) {
|
|
99
|
+
try {
|
|
100
|
+
await fsp.access(joinPath(dir, cand));
|
|
101
|
+
} // exists → try the next suffix
|
|
102
|
+
catch {
|
|
103
|
+
return cand;
|
|
104
|
+
} // ENOENT → free
|
|
105
|
+
cand = `${base} (${n})${ext}`;
|
|
106
|
+
}
|
|
107
|
+
return `${base} (${randomBytes(3).toString('hex')})${ext}`;
|
|
108
|
+
}
|
|
109
|
+
// Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
|
|
110
|
+
// buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
|
|
111
|
+
// non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
|
|
112
|
+
// in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
|
|
113
|
+
// the file part, so the field is known by the time the file event fires.
|
|
114
|
+
r.post('/upload', (req, res) => {
|
|
115
|
+
let bb;
|
|
116
|
+
// defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
|
|
117
|
+
// Content-Disposition as raw UTF-8 bytes; busboy's default 'latin1' would decode it to mojibake.
|
|
118
|
+
try {
|
|
119
|
+
bb = busboy({ headers: req.headers, defParamCharset: 'utf8', limits: { files: 1, fileSize: maxUploadBytes + 1 } });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return res.status(400).json({ error: 'bad multipart request' });
|
|
123
|
+
}
|
|
124
|
+
let dir = '';
|
|
125
|
+
let stash = false; // stash=1 → upload into the per-cwd space under ~/.handmux/uploads; `dir` is the cwd
|
|
126
|
+
let sawFile = false;
|
|
127
|
+
let handled = false;
|
|
128
|
+
let ws = null;
|
|
129
|
+
let tmp = null;
|
|
130
|
+
const done = (status, body) => {
|
|
131
|
+
if (!handled) {
|
|
132
|
+
handled = true;
|
|
133
|
+
res.status(status).json(body);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const cleanup = () => (tmp ? fsp.rm(tmp, { force: true }).catch(() => { }) : Promise.resolve());
|
|
137
|
+
bb.on('field', (name, val) => {
|
|
138
|
+
if (name === 'dir')
|
|
139
|
+
dir = val;
|
|
140
|
+
else if (name === 'stash')
|
|
141
|
+
stash = val === '1';
|
|
142
|
+
});
|
|
143
|
+
bb.on('file', async (_field, file, info) => {
|
|
144
|
+
sawFile = true;
|
|
145
|
+
try {
|
|
146
|
+
const name = safeUploadName(info.filename);
|
|
147
|
+
if (!name) {
|
|
148
|
+
file.resume();
|
|
149
|
+
return done(400, { error: 'bad filename' });
|
|
150
|
+
}
|
|
151
|
+
if (!isAllowedUploadExt(name, uploadExts)) {
|
|
152
|
+
file.resume();
|
|
153
|
+
return done(415, { error: 'type not allowed' });
|
|
154
|
+
}
|
|
155
|
+
const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
|
|
156
|
+
if ('error' in target) {
|
|
157
|
+
file.resume();
|
|
158
|
+
return done(target.status, { error: target.error });
|
|
159
|
+
}
|
|
160
|
+
// Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
|
|
161
|
+
// Resolved up front for the response; re-resolved at link time if the race is lost (see below).
|
|
162
|
+
const origName = name;
|
|
163
|
+
let finalName = await freeUploadName(target.real, origName);
|
|
164
|
+
let dest = joinPath(target.real, finalName);
|
|
165
|
+
const tempPath = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
|
|
166
|
+
tmp = tempPath;
|
|
167
|
+
const writeStream = createWriteStream(tempPath);
|
|
168
|
+
ws = writeStream;
|
|
169
|
+
writeStream.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
|
|
170
|
+
writeStream.on('finish', async () => {
|
|
171
|
+
// busboy sets file.truncated synchronously when it emits 'limit'; it's reliably true here
|
|
172
|
+
// if the stream exceeded the cap. (We size the limit at cap+1 so a file of EXACTLY
|
|
173
|
+
// maxUploadBytes is allowed; only strictly-larger trips it.)
|
|
174
|
+
if (file.truncated) {
|
|
175
|
+
await cleanup();
|
|
176
|
+
return done(413, { error: 'too large' });
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
|
|
180
|
+
// throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
|
|
181
|
+
// file, and a clash auto-suffixes rather than 409s.
|
|
182
|
+
let linked = false;
|
|
183
|
+
for (let attempt = 0; attempt < 6 && !linked; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
await fsp.link(tempPath, dest);
|
|
186
|
+
linked = true;
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (errorCode(e) !== 'EEXIST')
|
|
190
|
+
throw e;
|
|
191
|
+
finalName = await freeUploadName(target.real, origName);
|
|
192
|
+
dest = joinPath(target.real, finalName);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!linked) {
|
|
196
|
+
await cleanup();
|
|
197
|
+
return done(409, { error: 'exists' });
|
|
198
|
+
}
|
|
199
|
+
await cleanup(); // link made dest a second name for the data; drop the temp name
|
|
200
|
+
const st = await fsp.stat(dest);
|
|
201
|
+
done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
await cleanup();
|
|
205
|
+
done(500, { error: 'finalize failed' });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return void file.pipe(writeStream);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// resolveUploadDir / fs errors etc. — never let the async handler reject (busboy won't catch
|
|
212
|
+
// it → unhandledRejection + hung request).
|
|
213
|
+
file.resume();
|
|
214
|
+
await cleanup();
|
|
215
|
+
return done(500, { error: 'upload failed' });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
bb.on('error', () => { cleanup().finally(() => done(400, { error: 'parse error' })); });
|
|
219
|
+
bb.on('close', () => { if (!sawFile)
|
|
220
|
+
done(400, { error: 'no file' }); });
|
|
221
|
+
// Client aborted mid-upload (mobile networks drop constantly). req.pipe(bb) does NOT forward the
|
|
222
|
+
// source's destroy to busboy, so ws/file/bb emit nothing — we'd leak the half-written temp file
|
|
223
|
+
// and its fd on every dropped upload. Clean up ourselves on abort.
|
|
224
|
+
req.on('aborted', () => { if (handled)
|
|
225
|
+
return; handled = true; if (ws)
|
|
226
|
+
ws.destroy(); cleanup(); });
|
|
227
|
+
return req.pipe(bb);
|
|
228
|
+
});
|
|
229
|
+
return r;
|
|
230
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// git viewer (read-only). Each route calls the git data layer and maps its {error,status} to an HTTP
|
|
2
|
+
// status (same shape as the docs routes); the layer enforces the under-$HOME containment and validation.
|
|
3
|
+
import express from 'express';
|
|
4
|
+
export function gitRoutes({ git }) {
|
|
5
|
+
const r = express.Router();
|
|
6
|
+
const q = (value) => (typeof value === 'string' ? value : '');
|
|
7
|
+
r.get('/git/repos', async (req, res, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const out = await git.detectRepos(q(req.query.dir));
|
|
10
|
+
if ('error' in out)
|
|
11
|
+
return res.status(out.status).json({ error: out.error });
|
|
12
|
+
return res.json({ repos: out.repos });
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
return next(e);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
r.get('/git/status', async (req, res, next) => {
|
|
19
|
+
try {
|
|
20
|
+
const out = await git.status(q(req.query.repo));
|
|
21
|
+
if ('error' in out)
|
|
22
|
+
return res.status(out.status).json({ error: out.error });
|
|
23
|
+
return res.json({ changes: out.changes });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return next(e);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
r.get('/git/log', async (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
const out = await git.log(q(req.query.repo), req.query.limit, req.query.ref ? q(req.query.ref) : undefined);
|
|
32
|
+
if ('error' in out)
|
|
33
|
+
return res.status(out.status).json({ error: out.error });
|
|
34
|
+
return res.json({ commits: out.commits });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return next(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
r.get('/git/branches', async (req, res, next) => {
|
|
41
|
+
try {
|
|
42
|
+
const out = await git.branches(q(req.query.repo));
|
|
43
|
+
if ('error' in out)
|
|
44
|
+
return res.status(out.status).json({ error: out.error });
|
|
45
|
+
return res.json({ branches: out.branches });
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return next(e);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
r.get('/git/diff', async (req, res, next) => {
|
|
52
|
+
try {
|
|
53
|
+
const out = await git.diff(q(req.query.repo), {
|
|
54
|
+
path: q(req.query.path),
|
|
55
|
+
commit: req.query.commit ? q(req.query.commit) : undefined,
|
|
56
|
+
staged: req.query.staged === '1',
|
|
57
|
+
});
|
|
58
|
+
if ('error' in out)
|
|
59
|
+
return res.status(out.status).json({ error: out.error });
|
|
60
|
+
return res.json({ diff: out.diff, truncated: out.truncated });
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return next(e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
r.get('/git/commit', async (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const out = await git.commit(q(req.query.repo), q(req.query.hash));
|
|
69
|
+
if ('error' in out)
|
|
70
|
+
return res.status(out.status).json({ error: out.error });
|
|
71
|
+
return res.json({ message: out.message, files: out.files });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return next(e);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return r;
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
export function healthRoutes({ health, refresh = () => { }, }) {
|
|
3
|
+
const router = express.Router();
|
|
4
|
+
router.get('/health/live', (_req, res) => res.json(health.live()));
|
|
5
|
+
router.get('/health/ready', async (_req, res, next) => {
|
|
6
|
+
try {
|
|
7
|
+
await refresh();
|
|
8
|
+
const snapshot = health.snapshot();
|
|
9
|
+
return res.status(snapshot.ready ? 200 : 503).json(snapshot);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return next(error);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
return router;
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Read/delete for the manual-push inbox. Recording happens in routes/push.js (send-local). This
|
|
2
|
+
// module is single-purpose: list history (newest first) and delete one entry by id.
|
|
3
|
+
import express from 'express';
|
|
4
|
+
export function notificationRoutes({ notifications }) {
|
|
5
|
+
const r = express.Router();
|
|
6
|
+
r.get('/notifications', (req, res) => {
|
|
7
|
+
const device = req.query.device;
|
|
8
|
+
res.json({ items: typeof device === 'string' && device ? notifications.list(device) : [] });
|
|
9
|
+
});
|
|
10
|
+
r.delete('/notifications/:id', (req, res) => {
|
|
11
|
+
const device = req.query.device;
|
|
12
|
+
const id = req.params.id;
|
|
13
|
+
res.json({ ok: typeof device === 'string' && device && id ? notifications.remove(device, id) : false });
|
|
14
|
+
});
|
|
15
|
+
return r;
|
|
16
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Preview registry routes: register a static directory, list, and remove. Each registration returns a
|
|
2
|
+
// runtime-only capability URL; the main Handmux token is never exposed to preview content.
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { safePreviewName } from '../previews.js';
|
|
5
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
export function previewRoutes({ previews }) {
|
|
7
|
+
const r = express.Router();
|
|
8
|
+
// POST {name,dir} registers a static directory served at /preview/<name>/.
|
|
9
|
+
r.post('/previews', async (req, res, next) => {
|
|
10
|
+
if (!previews)
|
|
11
|
+
return res.status(503).json({ error: 'previews disabled' });
|
|
12
|
+
const body = req.body;
|
|
13
|
+
const name = isRecord(body) ? body.name : undefined;
|
|
14
|
+
const dir = isRecord(body) ? body.dir : undefined;
|
|
15
|
+
if (typeof name !== 'string' || !name || typeof dir !== 'string' || !dir)
|
|
16
|
+
return res.status(400).json({ error: 'bad request' });
|
|
17
|
+
try {
|
|
18
|
+
const out = await previews.register({ name, dir });
|
|
19
|
+
if ('error' in out)
|
|
20
|
+
return res.status(out.status).json({ error: out.error });
|
|
21
|
+
const url = `/preview/${encodeURIComponent(out.name)}/${encodeURIComponent(out.accessToken)}/`;
|
|
22
|
+
return res.json({ name: out.name, kind: out.kind, url, expiresAt: out.expiresAt });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return next(e);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
r.get('/previews', (_req, res) => {
|
|
29
|
+
if (!previews)
|
|
30
|
+
return res.status(503).json({ error: 'previews disabled' });
|
|
31
|
+
return res.json({ previews: previews.list() });
|
|
32
|
+
});
|
|
33
|
+
r.delete('/previews/:name', (req, res) => {
|
|
34
|
+
if (!previews)
|
|
35
|
+
return res.status(503).json({ error: 'previews disabled' });
|
|
36
|
+
const name = req.params.name;
|
|
37
|
+
if (!name || !safePreviewName(name))
|
|
38
|
+
return res.status(400).json({ error: 'bad name' });
|
|
39
|
+
previews.remove(name);
|
|
40
|
+
return res.status(204).end();
|
|
41
|
+
});
|
|
42
|
+
return r;
|
|
43
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Web Push routes: hand the client the VAPID key, store/update/remove a browser PushSubscription, and
|
|
2
|
+
// the local script-push send entry. The push module owns the delivery contract (TTL/topic/prune).
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { sanitizeNotificationUrl } from '../urlPolicy.js';
|
|
5
|
+
import { parsePushSubscription } from '../push.js';
|
|
6
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const strings = (value) => Array.isArray(value)
|
|
8
|
+
? value.filter((item) => typeof item === 'string' && item.length > 0)
|
|
9
|
+
: [];
|
|
10
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
11
|
+
export function pushRoutes({ push, notifications }) {
|
|
12
|
+
const r = express.Router();
|
|
13
|
+
// The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
|
|
14
|
+
r.get('/push/vapid', (_req, res) => {
|
|
15
|
+
if (!push.isConfigured())
|
|
16
|
+
return res.status(503).json({ error: 'push not configured' });
|
|
17
|
+
return res.json({ key: push.publicKey() });
|
|
18
|
+
});
|
|
19
|
+
// Store a browser PushSubscription, then immediately fire a welcome push back to it — so enabling
|
|
20
|
+
// the toggle proves the whole pipe (subscribe → push service → SW → notification) end to end.
|
|
21
|
+
r.post('/push/subscribe', async (req, res, next) => {
|
|
22
|
+
const body = req.body;
|
|
23
|
+
const sub = parsePushSubscription(isRecord(body) ? body.subscription : undefined);
|
|
24
|
+
const preferredPushKey = isRecord(body) ? body.pushKey : undefined;
|
|
25
|
+
const boundSessions = strings(isRecord(body) ? body.boundSessions : undefined);
|
|
26
|
+
if (!sub)
|
|
27
|
+
return res.status(400).json({ error: 'bad subscription' });
|
|
28
|
+
try {
|
|
29
|
+
const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey);
|
|
30
|
+
if (!pushKey)
|
|
31
|
+
return res.status(400).json({ error: 'bad subscription' });
|
|
32
|
+
const delivery = await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
|
|
33
|
+
// `deliver` deliberately contains failures so an automatic pane push can never break the polling
|
|
34
|
+
// loop. Enabling notifications is different: its welcome push is the end-to-end health check, so a
|
|
35
|
+
// rejected/dead subscription must not be reported to the phone as "enabled".
|
|
36
|
+
if (delivery.sent !== 1) {
|
|
37
|
+
const expired = delivery.gone > 0;
|
|
38
|
+
return res.status(expired ? 410 : 502).json({
|
|
39
|
+
error: expired ? 'push subscription expired' : 'push delivery rejected',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return res.json({ ok: true, count: push.count(), pushKey });
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return next(e);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
r.post('/push/unsubscribe', (req, res) => {
|
|
49
|
+
const body = req.body;
|
|
50
|
+
const endpoint = isRecord(body) ? body.endpoint : undefined;
|
|
51
|
+
const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint) : null;
|
|
52
|
+
return res.json({ ok: true, pushKey });
|
|
53
|
+
});
|
|
54
|
+
// Manual "send me a test" — pushes to every stored subscription.
|
|
55
|
+
r.post('/push/test', async (_req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const out = await push.sendToAll({ title: 'handmux 测试', body: '这是一条测试通知 — 点我回到 app', tag: 'handmux-test' }, { topic: 'handmux', urgency: 'high' });
|
|
58
|
+
const { deliveries: _deliveries, ...summary } = out;
|
|
59
|
+
return res.json(summary);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return next(e);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// Client reports which sessions this device cares about; updates the stored subscription.
|
|
66
|
+
r.post('/push/bound', (req, res) => {
|
|
67
|
+
const requestBody = req.body;
|
|
68
|
+
const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
|
|
69
|
+
const boundSessions = strings(isRecord(requestBody) ? requestBody.boundSessions : undefined);
|
|
70
|
+
if (typeof endpoint === 'string')
|
|
71
|
+
push.updateBound(endpoint, boundSessions);
|
|
72
|
+
return res.json({ ok: true });
|
|
73
|
+
});
|
|
74
|
+
// Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
|
|
75
|
+
// devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
|
|
76
|
+
r.post('/push/send-local', async (req, res, next) => {
|
|
77
|
+
const requestBody = req.body;
|
|
78
|
+
const data = isRecord(requestBody) ? requestBody : {};
|
|
79
|
+
const { title, body, tag, url } = data;
|
|
80
|
+
if (typeof title !== 'string' || !title.trim())
|
|
81
|
+
return res.status(400).json({ error: 'title required' });
|
|
82
|
+
if (typeof body !== 'string' || !body.trim())
|
|
83
|
+
return res.status(400).json({ error: 'body required' });
|
|
84
|
+
const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
|
|
85
|
+
if (url != null && !safeUrl)
|
|
86
|
+
return res.status(400).json({ error: 'url must be http(s) or relative' });
|
|
87
|
+
const sessions = strings(data.sessions);
|
|
88
|
+
const devices = strings(data.devices);
|
|
89
|
+
const hasSessions = sessions.length > 0;
|
|
90
|
+
const hasDevices = devices.length > 0;
|
|
91
|
+
if (hasSessions && hasDevices)
|
|
92
|
+
return res.status(400).json({ error: 'use --session or --device, not both' });
|
|
93
|
+
const payload = { title, body };
|
|
94
|
+
if (typeof tag === 'string' && tag)
|
|
95
|
+
payload.tag = tag;
|
|
96
|
+
const opts = { urgency: 'normal', ttl: 1800 };
|
|
97
|
+
if (typeof payload.tag === 'string')
|
|
98
|
+
opts.topic = payload.tag;
|
|
99
|
+
// Record FIRST so the notification tap can deep-link to this exact message's detail page. `--url`
|
|
100
|
+
// is stored on the record (surfaced in the detail), NOT used as the tap target.
|
|
101
|
+
let targetKeys = [];
|
|
102
|
+
let rec = null;
|
|
103
|
+
if (notifications) {
|
|
104
|
+
targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
|
|
105
|
+
rec = notifications.record(targetKeys, {
|
|
106
|
+
title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' },
|
|
107
|
+
});
|
|
108
|
+
payload.data = { inboxId: rec.id };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
|
|
112
|
+
: hasSessions ? await push.sendToSessions(sessions, payload, opts)
|
|
113
|
+
: await push.sendToAll(payload, opts);
|
|
114
|
+
if (notifications && rec) {
|
|
115
|
+
for (const delivery of out.deliveries || []) {
|
|
116
|
+
if (!delivery.pushKey)
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Delivery already happened. Do not turn a status-metadata write failure into an HTTP error:
|
|
123
|
+
// CLI automation would retry and send the real notification twice. The pending record remains
|
|
124
|
+
// honest (unknown final state) and the server log retains the storage failure for diagnosis.
|
|
125
|
+
console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const { deliveries: _deliveries, ...summary } = out;
|
|
130
|
+
return res.json(summary);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return next(e);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// This device's addressing key (server-token auth) — the script push sheet reads it to show `--device`.
|
|
137
|
+
r.post('/push/key', (req, res) => {
|
|
138
|
+
const requestBody = req.body;
|
|
139
|
+
const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
|
|
140
|
+
if (typeof endpoint !== 'string')
|
|
141
|
+
return res.status(400).json({ error: 'endpoint required' });
|
|
142
|
+
return res.json({ pushKey: push.getPushKey(endpoint) });
|
|
143
|
+
});
|
|
144
|
+
return r;
|
|
145
|
+
}
|