handmux 0.23.0 → 0.25.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 +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -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 +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -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/piExtension.js +155 -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 +154 -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 +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- 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 +3436 -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 +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -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 +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -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 +167 -0
- package/dist/src/paneInput.js +42 -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 +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -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 +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- 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 +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -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 +363 -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 +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- 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/claudeHooks.js +0 -206
- 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/statusLine.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/server.js +0 -186
- 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/{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
package/src/docs.js
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
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
|
-
// `knownMtime` (ms) makes this a conditional read: if the file's mtime is unchanged the body is
|
|
51
|
-
// skipped and `{ notModified: true }` comes back (no readFile), so re-visiting an unchanged doc on
|
|
52
|
-
// the phone neither transfers content nor forces a re-render. `mtimeMs` is always returned so the
|
|
53
|
-
// client can store it for the next check.
|
|
54
|
-
async function readDoc(rawPath, knownMtime = null) {
|
|
55
|
-
if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
56
|
-
const type = docTypeFor(rawPath);
|
|
57
|
-
if (!type) return { error: 'bad extension', status: 400 };
|
|
58
|
-
let real;
|
|
59
|
-
try { real = await fs.realpath(rawPath); }
|
|
60
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
61
|
-
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
62
|
-
let st;
|
|
63
|
-
try { st = await fs.stat(real); }
|
|
64
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
65
|
-
if (!st.isFile()) return { error: 'not a file', status: 400 };
|
|
66
|
-
if (st.size > MAX_READ_BYTES) return { error: 'too large', status: 413 };
|
|
67
|
-
const mtimeMs = st.mtimeMs;
|
|
68
|
-
if (knownMtime != null && mtimeMs === knownMtime) return { name: basename(real), type, mtimeMs, notModified: true };
|
|
69
|
-
const content = await fs.readFile(real, 'utf8');
|
|
70
|
-
return { name: basename(real), type, content, mtimeMs };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function listDir(rawPath) {
|
|
74
|
-
const rh = await realHomeP;
|
|
75
|
-
const roots = await rootsP;
|
|
76
|
-
let real;
|
|
77
|
-
try { real = await fs.realpath(rawPath ? rawPath : home); }
|
|
78
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
79
|
-
if (!rootOf(real, roots)) return { error: 'outside home', status: 400 };
|
|
80
|
-
const st = await fs.stat(real);
|
|
81
|
-
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
82
|
-
const dirents = await fs.readdir(real, { withFileTypes: true });
|
|
83
|
-
// withFileTypes uses lstat semantics: d.isFile() is false for symlinks, so symlinks are
|
|
84
|
-
// intentionally NOT listed (security property). Every regular file is listed now (not just
|
|
85
|
-
// docs); the extension decides whether it opens in-app as a doc ('doc'), an image viewer
|
|
86
|
-
// ('image'), or can only be downloaded ('file').
|
|
87
|
-
const dirEntries = [];
|
|
88
|
-
const fileDirents = [];
|
|
89
|
-
for (const d of dirents) {
|
|
90
|
-
if (d.isDirectory()) dirEntries.push({ name: d.name, type: 'dir' });
|
|
91
|
-
else if (d.isFile()) fileDirents.push(d);
|
|
92
|
-
}
|
|
93
|
-
// stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
|
|
94
|
-
// (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
|
|
95
|
-
const fileEntries = await Promise.all(fileDirents.map(async (d) => {
|
|
96
|
-
const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
|
|
97
|
-
let size = 0;
|
|
98
|
-
try { size = (await fs.stat(join(real, d.name))).size; } catch { /* gone mid-list → size 0 */ }
|
|
99
|
-
return { name: d.name, type, size };
|
|
100
|
-
}));
|
|
101
|
-
const entries = [...dirEntries, ...fileEntries];
|
|
102
|
-
// dirs first; files (doc+file) interleaved alphabetically
|
|
103
|
-
entries.sort((a, b) =>
|
|
104
|
-
(a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
105
|
-
// "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
|
|
106
|
-
const parent = roots.includes(real) ? null : join(real, '..');
|
|
107
|
-
return { path: real, home: rh, roots, parent, entries };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
|
|
111
|
-
// readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
|
|
112
|
-
async function statForDownload(rawPath) {
|
|
113
|
-
if (typeof rawPath !== 'string' || rawPath[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
114
|
-
let real;
|
|
115
|
-
try { real = await fs.realpath(rawPath); }
|
|
116
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
117
|
-
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
118
|
-
let st;
|
|
119
|
-
try { st = await fs.stat(real); }
|
|
120
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
121
|
-
if (!st.isFile()) return { error: 'not a file', status: 400 };
|
|
122
|
-
if (st.size > maxDownloadBytes) return { error: 'too large', status: 413 };
|
|
123
|
-
return { real, name: basename(real), size: st.size, mtimeMs: st.mtimeMs };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Resolve a path for UPLOAD TARGET: a directory under one of the allowed roots, not inside any
|
|
127
|
-
// hidden directory (relative to its own root). The $HOME root itself is off-limits (don't litter
|
|
128
|
-
// the home dir); an extra root like /tmp IS uploadable directly, since dropping files there is the
|
|
129
|
-
// whole point. Same realpath boundary as the rest.
|
|
130
|
-
async function resolveUploadDir(rawDir) {
|
|
131
|
-
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
132
|
-
const rh = await realHomeP;
|
|
133
|
-
let real;
|
|
134
|
-
try { real = await fs.realpath(rawDir); }
|
|
135
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
136
|
-
const root = rootOf(real, await rootsP);
|
|
137
|
-
if (!root) return { error: 'outside home', status: 400 };
|
|
138
|
-
if (real === rh) return { error: 'home root not allowed', status: 400 };
|
|
139
|
-
if (hasHiddenSegment(real, root)) return { error: 'hidden directory not allowed', status: 400 };
|
|
140
|
-
let st;
|
|
141
|
-
try { st = await fs.stat(real); }
|
|
142
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
143
|
-
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
144
|
-
return { real };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Resolve the STASH upload target: a per-project folder under ~/.handmux/uploads, created on
|
|
148
|
-
// demand. Deliberately OUTSIDE the user's project trees (so uploads can never be `git add`-ed by
|
|
149
|
-
// accident) yet still under $HOME, so the absolute path the chat box pastes stays readable by an
|
|
150
|
-
// agent running anywhere. Mirrors Claude Code's per-project layout: the caller's cwd is flattened
|
|
151
|
-
// ('/'→'-') into a single path segment (see encodeCwdKey), so each working directory gets its own
|
|
152
|
-
// space; an unknown cwd falls into `_default`. Returns the realpath'd dir.
|
|
153
|
-
async function resolveStashDir(rawCwd) {
|
|
154
|
-
const rh = await realHomeP;
|
|
155
|
-
const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
|
|
156
|
-
try { await fs.mkdir(target, { recursive: true }); }
|
|
157
|
-
catch { return { error: 'mkdir failed', status: 500 }; }
|
|
158
|
-
let real;
|
|
159
|
-
try { real = await fs.realpath(target); } // re-resolve in case a segment is a symlink
|
|
160
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
161
|
-
if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
|
|
162
|
-
return { real };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Resolve a path for use as a NEW session/window CWD: any directory under home (home root IS
|
|
166
|
-
// allowed, and hidden dirs ARE allowed — the browser can navigate into them). Same realpath +
|
|
167
|
-
// isUnder boundary as the rest; symlinks escaping home resolve outside → rejected.
|
|
168
|
-
async function resolveCwd(rawDir) {
|
|
169
|
-
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
170
|
-
const rh = await realHomeP;
|
|
171
|
-
let real;
|
|
172
|
-
try { real = await fs.realpath(rawDir); }
|
|
173
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
174
|
-
if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
|
|
175
|
-
let st;
|
|
176
|
-
try { st = await fs.stat(real); }
|
|
177
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
178
|
-
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
179
|
-
return { real };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Resolve a directory for BROWSE-side ops (the mkdir target): a directory under ANY allowed root
|
|
183
|
-
// (the root itself + hidden dirs allowed, like resolveCwd but multi-root). resolveCwd stays
|
|
184
|
-
// home-only — a new session's cwd should never land in a temp root by accident.
|
|
185
|
-
async function resolveBrowseDir(rawDir) {
|
|
186
|
-
if (typeof rawDir !== 'string' || rawDir[0] !== '/') return { error: 'not absolute', status: 400 };
|
|
187
|
-
let real;
|
|
188
|
-
try { real = await fs.realpath(rawDir); }
|
|
189
|
-
catch { return { error: 'not found', status: 404 }; }
|
|
190
|
-
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
191
|
-
let st;
|
|
192
|
-
try { st = await fs.stat(real); }
|
|
193
|
-
catch { return { error: 'not accessible', status: 404 }; }
|
|
194
|
-
if (!st.isDirectory()) return { error: 'not a directory', status: 400 };
|
|
195
|
-
return { real };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Create a new directory `name` inside `parentRaw`. The parent must be a directory under one of
|
|
199
|
-
// the allowed roots (root + hidden dirs allowed). `name` must be a single safe path segment.
|
|
200
|
-
async function makeDir(parentRaw, name) {
|
|
201
|
-
const nm = typeof name === 'string' ? name.trim() : '';
|
|
202
|
-
if (!nm || nm === '.' || nm === '..' || nm.includes('/') || nm.includes('\\') || nm.includes('\0')) {
|
|
203
|
-
return { error: 'bad name', status: 400 };
|
|
204
|
-
}
|
|
205
|
-
const parent = await resolveBrowseDir(parentRaw); // realpath + under-a-root + isDirectory
|
|
206
|
-
if (parent.error) return parent;
|
|
207
|
-
const target = join(parent.real, nm);
|
|
208
|
-
try {
|
|
209
|
-
await fs.mkdir(target);
|
|
210
|
-
} catch (e) {
|
|
211
|
-
if (e.code === 'EEXIST') return { error: 'exists', status: 409 };
|
|
212
|
-
return { error: 'mkdir failed', status: 500 };
|
|
213
|
-
}
|
|
214
|
-
return { real: target };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return { readDoc, listDir, statForDownload, resolveUploadDir, resolveStashDir, resolveCwd, makeDir };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// The extra (outside-$HOME) roots the file browser may reach: the system temp dir and, if set, the
|
|
221
|
-
// per-user $TMPDIR (on macOS that's /var/folders/.../T). Missing ones are skipped at resolve time.
|
|
222
|
-
export function defaultExtraRoots(env = process.env) {
|
|
223
|
-
const roots = ['/tmp'];
|
|
224
|
-
if (env.TMPDIR) roots.push(env.TMPDIR);
|
|
225
|
-
return roots;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export const defaultDocs = createDocs({ home: homedir(), extraRoots: defaultExtraRoots() });
|
package/src/git.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
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
|
-
import { defaultExtraRoots } from './docs.js';
|
|
7
|
-
|
|
8
|
-
// 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
|
|
9
|
-
const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
|
|
10
|
-
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
export function createGit({ home, extraRoots = [] } = {}) {
|
|
13
|
-
const realHomeP = fs.realpath(home);
|
|
14
|
-
// Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
|
|
15
|
-
// an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
|
|
16
|
-
// missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
|
|
17
|
-
// lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
|
|
18
|
-
// /tmp with a red "outside home".
|
|
19
|
-
const rootsP = (async () => {
|
|
20
|
-
const rh = await realHomeP;
|
|
21
|
-
const out = [rh];
|
|
22
|
-
for (const r of extraRoots) {
|
|
23
|
-
if (typeof r !== 'string' || !r) continue;
|
|
24
|
-
let real;
|
|
25
|
-
try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
|
|
26
|
-
if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
|
|
27
|
-
out.push(real);
|
|
28
|
-
}
|
|
29
|
-
return out;
|
|
30
|
-
})();
|
|
31
|
-
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
32
|
-
const rootOf = (real, roots) => {
|
|
33
|
-
let best = null;
|
|
34
|
-
for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
|
|
35
|
-
return best;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function git(cwd, args) {
|
|
39
|
-
const sub = args[0];
|
|
40
|
-
if (!READONLY.has(sub)) return Promise.reject(new Error(`blocked subcommand: ${sub}`));
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
execFile('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], { maxBuffer: MAX_BUFFER }, (err, stdout) => {
|
|
43
|
-
if (err) reject(err); else resolve(stdout);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function resolveRepo(rawPath) {
|
|
49
|
-
if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
|
|
50
|
-
let real;
|
|
51
|
-
try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
|
|
52
|
-
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
53
|
-
return { real };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function isRepo(dir) {
|
|
57
|
-
try { return (await git(dir, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true'; }
|
|
58
|
-
catch { return false; }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// `realDir` is used for git operations; `displayPath` is what we expose (the caller's original path,
|
|
62
|
-
// avoiding macOS /private/var vs /var confusion when the caller passed a non-realpath'd path).
|
|
63
|
-
async function repoMeta(realDir, displayPath) {
|
|
64
|
-
const p = displayPath ?? realDir;
|
|
65
|
-
let branch = 'HEAD';
|
|
66
|
-
try { branch = (await git(realDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); } catch { /* empty repo */ }
|
|
67
|
-
let dirty = false;
|
|
68
|
-
try { dirty = (await git(realDir, ['status', '--porcelain'])).trim().length > 0; } catch { /* ignore */ }
|
|
69
|
-
return { name: basename(p), path: p, branch, dirty };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function detectRepos(rawDir) {
|
|
73
|
-
const r = await resolveRepo(rawDir);
|
|
74
|
-
if (r.error) return r;
|
|
75
|
-
if (await isRepo(r.real)) return { repos: [await repoMeta(r.real, rawDir)] };
|
|
76
|
-
const repos = [];
|
|
77
|
-
let dirents = [];
|
|
78
|
-
try { dirents = await fs.readdir(r.real, { withFileTypes: true }); } catch { /* ignore */ }
|
|
79
|
-
for (const d of dirents) {
|
|
80
|
-
if (!d.isDirectory()) continue;
|
|
81
|
-
const child = join(r.real, d.name);
|
|
82
|
-
const childDisplay = join(rawDir, d.name);
|
|
83
|
-
if (await isRepo(child)) repos.push(await repoMeta(child, childDisplay));
|
|
84
|
-
}
|
|
85
|
-
repos.sort((a, b) => a.name.localeCompare(b.name));
|
|
86
|
-
return { repos };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function status(rawRepo) {
|
|
90
|
-
const r = await resolveRepo(rawRepo);
|
|
91
|
-
if (r.error) return r;
|
|
92
|
-
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
93
|
-
let out;
|
|
94
|
-
try { out = await git(r.real, ['status', '--porcelain']); }
|
|
95
|
-
catch { return { error: 'git error', status: 500 }; }
|
|
96
|
-
const changes = out.split('\n').filter(Boolean).map((line) => {
|
|
97
|
-
const x = line[0], y = line[1];
|
|
98
|
-
let path = line.slice(3);
|
|
99
|
-
const arrow = path.indexOf(' -> ');
|
|
100
|
-
if (arrow >= 0) path = path.slice(arrow + 4);
|
|
101
|
-
return { x: x === ' ' ? '' : x, y: y === ' ' ? '' : y, path };
|
|
102
|
-
});
|
|
103
|
-
return { changes };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const SEP = '\x1f';
|
|
107
|
-
|
|
108
|
-
// 分支 / ref 名校验:挡选项注入(开头 '-')、路径穿越('..')、NUL,并限定到安全字符子集。
|
|
109
|
-
// 只读 git log 用,失败时 git 自己会再拒一次。
|
|
110
|
-
function safeRef(ref) {
|
|
111
|
-
if (typeof ref !== 'string' || !ref) return null;
|
|
112
|
-
if (ref[0] === '-' || ref.includes('..') || ref.includes('\0')) return null;
|
|
113
|
-
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) return null;
|
|
114
|
-
return ref;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function log(rawRepo, limit = 50, ref) {
|
|
118
|
-
const r = await resolveRepo(rawRepo);
|
|
119
|
-
if (r.error) return r;
|
|
120
|
-
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
121
|
-
const n = Math.max(1, Math.min(500, Number(limit) || 50));
|
|
122
|
-
const safeR = ref == null || ref === '' ? null : safeRef(ref);
|
|
123
|
-
if (ref && !safeR) return { error: 'bad ref', status: 400 };
|
|
124
|
-
const args = ['log', `-n${n}`, `--pretty=format:%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`];
|
|
125
|
-
if (safeR) args.push(safeR); // git log <ref> …(指定分支只读看历史,不动工作树)
|
|
126
|
-
let out = '';
|
|
127
|
-
try { out = await git(r.real, args); }
|
|
128
|
-
catch { return { commits: [] }; } // 空仓库(无提交)/ 无此 ref → 空列表
|
|
129
|
-
const commits = out.split('\n').filter(Boolean).map((line) => {
|
|
130
|
-
const [hash, short, subject, author, relDate] = line.split(SEP);
|
|
131
|
-
return { hash, short, subject, author, relDate };
|
|
132
|
-
});
|
|
133
|
-
return { commits };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function branches(rawRepo) {
|
|
137
|
-
const r = await resolveRepo(rawRepo);
|
|
138
|
-
if (r.error) return r;
|
|
139
|
-
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
140
|
-
const fmt = ['%(refname:short)', '%(HEAD)', '%(upstream:short)', '%(upstream:track)'].join(SEP);
|
|
141
|
-
let out;
|
|
142
|
-
try { out = await git(r.real, ['for-each-ref', `--format=${fmt}`, 'refs/heads']); }
|
|
143
|
-
catch { return { branches: [] }; }
|
|
144
|
-
const branches = out.split('\n').filter(Boolean).map((line) => {
|
|
145
|
-
const [name, head, upstream, track] = line.split(SEP);
|
|
146
|
-
const ahead = Number((track.match(/ahead (\d+)/) || [])[1] || 0);
|
|
147
|
-
const behind = Number((track.match(/behind (\d+)/) || [])[1] || 0);
|
|
148
|
-
return { name, current: head === '*', upstream: upstream || null, ahead, behind };
|
|
149
|
-
});
|
|
150
|
-
return { branches };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// 文件路径校验:相对、非绝对、不以 '-' 开头(防选项注入)、无 '..' 段、无 NUL。
|
|
154
|
-
function safeRelPath(p) {
|
|
155
|
-
if (typeof p !== 'string' || !p || p[0] === '-' || isAbsolute(p)) return null;
|
|
156
|
-
if (p.includes('\0') || p.split('/').some((seg) => seg === '..')) return null;
|
|
157
|
-
return p;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const MAX_DIFF_BYTES = 512 * 1024;
|
|
161
|
-
function cap(text) {
|
|
162
|
-
if (text.length <= MAX_DIFF_BYTES) return { diff: text, truncated: false };
|
|
163
|
-
return { diff: text.slice(0, MAX_DIFF_BYTES), truncated: true };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// diff 语义:仅 path → 工作区 vs HEAD;staged → 暂存区 vs HEAD;commit → 该提交 vs 其父。
|
|
167
|
-
async function diff(rawRepo, { path, commit, staged } = {}) {
|
|
168
|
-
const r = await resolveRepo(rawRepo);
|
|
169
|
-
if (r.error) return r;
|
|
170
|
-
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
171
|
-
const rel = safeRelPath(path);
|
|
172
|
-
if (!rel) return { error: 'bad path', status: 400 };
|
|
173
|
-
let args;
|
|
174
|
-
if (commit) {
|
|
175
|
-
if (!/^[0-9a-fA-F]{4,40}$/.test(commit)) return { error: 'bad commit', status: 400 };
|
|
176
|
-
args = ['show', '--format=', commit, '--', rel];
|
|
177
|
-
} else if (staged) {
|
|
178
|
-
args = ['diff', '--staged', '--', rel];
|
|
179
|
-
} else {
|
|
180
|
-
args = ['diff', 'HEAD', '--', rel];
|
|
181
|
-
}
|
|
182
|
-
let out = '';
|
|
183
|
-
try { out = await git(r.real, args); } catch (e) { return { error: 'diff failed', status: 500 }; }
|
|
184
|
-
return cap(out);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function commit(rawRepo, hash) {
|
|
188
|
-
const r = await resolveRepo(rawRepo);
|
|
189
|
-
if (r.error) return r;
|
|
190
|
-
if (!(await isRepo(r.real))) return { error: 'not a repo', status: 400 };
|
|
191
|
-
if (!/^[0-9a-fA-F]{4,40}$/.test(hash)) return { error: 'bad commit', status: 400 };
|
|
192
|
-
let message, ns;
|
|
193
|
-
try {
|
|
194
|
-
message = (await git(r.real, ['show', '-s', '--format=%B', hash])).trim();
|
|
195
|
-
// --root 让首次提交(无父)也能列出文件。
|
|
196
|
-
ns = await git(r.real, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
|
|
197
|
-
} catch { return { error: 'git error', status: 500 }; }
|
|
198
|
-
const files = ns.split('\n').filter(Boolean).map((line) => {
|
|
199
|
-
const [code, ...rest] = line.split('\t');
|
|
200
|
-
return { x: code[0], y: '', path: rest[rest.length - 1] };
|
|
201
|
-
});
|
|
202
|
-
return { message, files };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
|
package/src/httpApi.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// API composition root. Applies auth + JSON parsing, then mounts the per-domain route modules under
|
|
2
|
-
// /api. Each module (routes/*.js) owns one domain and receives only the deps it needs; the closure deps
|
|
3
|
-
// (commands, docs, git, previews, token, home, …) pass straight through. Behaviour is unchanged from when
|
|
4
|
-
// every route lived here — this file is just the wiring.
|
|
5
|
-
import express from 'express';
|
|
6
|
-
import { expressAuth } from './auth.js';
|
|
7
|
-
import * as defaultCommands from './tmux/commands.js';
|
|
8
|
-
import { defaultDocs, MAX_TRANSFER_BYTES } from './docs.js';
|
|
9
|
-
import { defaultGit } from './git.js';
|
|
10
|
-
import * as push from './push.js';
|
|
11
|
-
import * as notifications from './notifications.js';
|
|
12
|
-
import { createClaudeEvents } from './claudeEvents.js';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
|
|
15
|
-
import { claudeStatePath } from './cli/state.js';
|
|
16
|
-
import { sessionRoutes } from './routes/sessions.js';
|
|
17
|
-
import { terminalRoutes } from './routes/terminal.js';
|
|
18
|
-
import { gitRoutes } from './routes/git.js';
|
|
19
|
-
import { fileRoutes } from './routes/files.js';
|
|
20
|
-
import { pushRoutes } from './routes/push.js';
|
|
21
|
-
import { systemRoutes } from './routes/system.js';
|
|
22
|
-
import { previewRoutes } from './routes/previews.js';
|
|
23
|
-
import { notificationRoutes } from './routes/notifications.js';
|
|
24
|
-
import { transcriptRoutes } from './routes/transcript.js';
|
|
25
|
-
import { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
|
|
26
|
-
import { workspaceRoutes } from './routes/workspace.js';
|
|
27
|
-
import { browserRoutes } from './browser/routes.js';
|
|
28
|
-
import { codexRoutes } from './routes/codex.js';
|
|
29
|
-
|
|
30
|
-
// Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
|
|
31
|
-
export { isAllowedKey } from './routes/terminal.js';
|
|
32
|
-
|
|
33
|
-
export function createApiRouter({
|
|
34
|
-
token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
|
|
35
|
-
uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES,
|
|
36
|
-
asrEnv = process.env, previews,
|
|
37
|
-
shortcuts = DEFAULT_SHORTCUTS,
|
|
38
|
-
browser,
|
|
39
|
-
browserBootstrap,
|
|
40
|
-
previewDomain,
|
|
41
|
-
workspace,
|
|
42
|
-
codexApp,
|
|
43
|
-
home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
|
|
44
|
-
} = {}) {
|
|
45
|
-
const r = express.Router();
|
|
46
|
-
r.use(expressAuth(token));
|
|
47
|
-
r.use(express.json());
|
|
48
|
-
const claudeEvents = events || createClaudeEvents({ commands, push });
|
|
49
|
-
|
|
50
|
-
const deps = {
|
|
51
|
-
token, commands, docs, git, push, notifications, claudeEvents,
|
|
52
|
-
uploadExts, maxUploadBytes, asrEnv, previews, shortcuts, home, stateFile, workspace, browser, browserBootstrap, previewDomain, codexApp,
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
r.use(sessionRoutes(deps));
|
|
56
|
-
r.use(terminalRoutes(deps));
|
|
57
|
-
r.use(gitRoutes(deps));
|
|
58
|
-
r.use(fileRoutes(deps));
|
|
59
|
-
r.use(pushRoutes(deps));
|
|
60
|
-
r.use(notificationRoutes(deps));
|
|
61
|
-
r.use(systemRoutes(deps));
|
|
62
|
-
r.use(previewRoutes(deps));
|
|
63
|
-
// In the main process browser APIs are forwarded to the isolated worker before this router. Keep
|
|
64
|
-
// preview-domain wiring out of this unused fallback route so capability reporting does not require
|
|
65
|
-
// the worker-owned bootstrap ticket store.
|
|
66
|
-
r.use('/browser-proxy', browserRoutes(browser ? deps : { ...deps, previewDomain: null }));
|
|
67
|
-
r.use(transcriptRoutes(deps));
|
|
68
|
-
r.use(codexRoutes(deps));
|
|
69
|
-
if (workspace) r.use(workspaceRoutes(deps));
|
|
70
|
-
|
|
71
|
-
return r;
|
|
72
|
-
}
|
package/src/jsonStore.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Shared JSON-store persistence for the small single-writer runtime registries (push subs, previews).
|
|
2
|
-
// Both hold their records IN MEMORY (loaded once at startup) and flush the whole array on each mutation —
|
|
3
|
-
// there is only ever one server process, so the in-memory copy is the source of truth and a read never
|
|
4
|
-
// touches disk. Writes are atomic (tmp + rename) so a concurrent reader (or a crash mid-write) can never
|
|
5
|
-
// observe a half-written file. Persistence is best-effort: a lost flush just means the client re-subscribes
|
|
6
|
-
// / the preview is re-registered next launch.
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
|
|
10
|
-
// Read a JSON array from `file`, tolerating missing/corrupt/non-array content (→ []). Never throws.
|
|
11
|
-
export function readJsonArray(file) {
|
|
12
|
-
try { const v = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(v) ? v : []; }
|
|
13
|
-
catch { return []; }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Atomic write: serialize `obj` to a sibling .tmp then rename over `file` (atomic on the same fs).
|
|
17
|
-
export function writeJsonAtomic(file, obj) {
|
|
18
|
-
try {
|
|
19
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
20
|
-
const tmp = `${file}.tmp`;
|
|
21
|
-
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
22
|
-
fs.renameSync(tmp, file);
|
|
23
|
-
} catch { /* best effort — a lost flush is recoverable by the client re-registering */ }
|
|
24
|
-
}
|
package/src/notifications.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Per-device manual-push inbox: each subscribed device gets its own file `<NOTIF_DIR>/<pushKey>.json`, so a
|
|
2
|
-
// `--device`/`--session`-scoped push only lands in the targeted devices' inboxes (delete/read are naturally
|
|
3
|
-
// per-device too). NOTIF_DIR is injected by the CLI (~/.handmux/notifications) — NEVER the package-internal
|
|
4
|
-
// default, which a global reinstall wipes. Low-frequency, so each op is a plain read-modify-write of one
|
|
5
|
-
// device file (no in-memory state). The same push shares one record id across its target devices so a
|
|
6
|
-
// notification tap's inboxId resolves on whichever device opens it.
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import crypto from 'node:crypto';
|
|
10
|
-
import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
|
|
11
|
-
import { sanitizeNotificationUrl } from './urlPolicy.js';
|
|
12
|
-
|
|
13
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
|
|
15
|
-
const CAP = 100;
|
|
16
|
-
const genId = () => crypto.randomBytes(9).toString('base64url');
|
|
17
|
-
|
|
18
|
-
// pushKey is base64url already; sanitize anyway so a hostile value can't escape DIR. Empty → null (skip).
|
|
19
|
-
function fileFor(key) {
|
|
20
|
-
const safe = String(key || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
21
|
-
return safe ? path.join(DIR, `${safe}.json`) : null;
|
|
22
|
-
}
|
|
23
|
-
const load = (file) => readJsonArray(file).filter((n) => n && typeof n.title === 'string');
|
|
24
|
-
|
|
25
|
-
export function record(pushKeys, { title, body, tag, url, delivery } = {}) {
|
|
26
|
-
const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
|
|
27
|
-
if (tag) rec.tag = String(tag);
|
|
28
|
-
if (delivery?.status === 'pending') rec.delivery = { status: 'pending' };
|
|
29
|
-
const safeUrl = sanitizeNotificationUrl(url);
|
|
30
|
-
if (safeUrl) rec.url = safeUrl;
|
|
31
|
-
for (const key of pushKeys || []) {
|
|
32
|
-
const file = fileFor(key);
|
|
33
|
-
if (!file) continue;
|
|
34
|
-
const items = load(file);
|
|
35
|
-
items.push(rec);
|
|
36
|
-
writeJsonAtomic(file, items.length > CAP ? items.slice(items.length - CAP) : items);
|
|
37
|
-
}
|
|
38
|
-
return rec;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function updateDelivery(pushKey, id, delivery) {
|
|
42
|
-
const file = fileFor(pushKey);
|
|
43
|
-
if (!file) return false;
|
|
44
|
-
const items = load(file);
|
|
45
|
-
const item = items.find((n) => n.id === id);
|
|
46
|
-
if (!item) return false;
|
|
47
|
-
const status = delivery?.status;
|
|
48
|
-
if (!['pending', 'success', 'failed'].includes(status)) return false;
|
|
49
|
-
item.delivery = { status };
|
|
50
|
-
if (status === 'failed' && typeof delivery.reason === 'string') item.delivery.reason = delivery.reason;
|
|
51
|
-
writeJsonAtomic(file, items);
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function list(pushKey) {
|
|
56
|
-
const file = fileFor(pushKey);
|
|
57
|
-
return file ? load(file).reverse() : [];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function remove(pushKey, id) {
|
|
61
|
-
const file = fileFor(pushKey);
|
|
62
|
-
if (!file) return false;
|
|
63
|
-
const items = load(file);
|
|
64
|
-
const kept = items.filter((n) => n.id !== id);
|
|
65
|
-
if (kept.length === items.length) return false;
|
|
66
|
-
writeJsonAtomic(file, kept);
|
|
67
|
-
return true;
|
|
68
|
-
}
|