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/dist/src/git.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
|
|
9
|
+
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
10
|
+
export function createGit({ home = homedir(), extraRoots = [] } = {}) {
|
|
11
|
+
const realHomeP = fs.realpath(home);
|
|
12
|
+
// Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
|
|
13
|
+
// an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
|
|
14
|
+
// missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
|
|
15
|
+
// lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
|
|
16
|
+
// /tmp with a red "outside home".
|
|
17
|
+
const rootsP = (async () => {
|
|
18
|
+
const rh = await realHomeP;
|
|
19
|
+
const out = [rh];
|
|
20
|
+
for (const root of extraRoots) {
|
|
21
|
+
if (typeof root !== 'string' || !root)
|
|
22
|
+
continue;
|
|
23
|
+
let real;
|
|
24
|
+
try {
|
|
25
|
+
real = await fs.realpath(root);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
} // not present on this host → skip
|
|
30
|
+
if (isUnder(real, rh) || out.includes(real))
|
|
31
|
+
continue; // already covered by home / dup
|
|
32
|
+
out.push(real);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
})();
|
|
36
|
+
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
37
|
+
const rootOf = (real, roots) => {
|
|
38
|
+
let best = null;
|
|
39
|
+
for (const root of roots)
|
|
40
|
+
if (isUnder(real, root) && (!best || root.length > best.length))
|
|
41
|
+
best = root;
|
|
42
|
+
return best;
|
|
43
|
+
};
|
|
44
|
+
function git(cwd, args) {
|
|
45
|
+
const sub = args[0];
|
|
46
|
+
if (!sub || !READONLY.has(sub))
|
|
47
|
+
return Promise.reject(new Error(`blocked subcommand: ${sub}`));
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
execFile('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], { maxBuffer: MAX_BUFFER, encoding: 'utf8' }, (err, stdout) => {
|
|
50
|
+
if (err)
|
|
51
|
+
reject(err);
|
|
52
|
+
else
|
|
53
|
+
resolve(stdout);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function resolveRepo(rawPath) {
|
|
58
|
+
if (typeof rawPath !== 'string' || !isAbsolute(rawPath))
|
|
59
|
+
return { error: 'not absolute', status: 400 };
|
|
60
|
+
let real;
|
|
61
|
+
try {
|
|
62
|
+
real = await fs.realpath(rawPath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { error: 'not found', status: 404 };
|
|
66
|
+
}
|
|
67
|
+
if (!rootOf(real, await rootsP))
|
|
68
|
+
return { error: 'outside home', status: 400 };
|
|
69
|
+
return { real };
|
|
70
|
+
}
|
|
71
|
+
async function isRepo(dir) {
|
|
72
|
+
try {
|
|
73
|
+
return (await git(dir, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true';
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function worktree(rawDir) {
|
|
80
|
+
const r = await resolveRepo(rawDir);
|
|
81
|
+
if ('error' in r)
|
|
82
|
+
return r;
|
|
83
|
+
try {
|
|
84
|
+
const path = (await git(r.real, ['rev-parse', '--show-toplevel'])).trim();
|
|
85
|
+
return path ? { worktree: { path } } : { worktree: null };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { worktree: null };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// `realDir` is used for git operations; `displayPath` is what we expose (the caller's original path,
|
|
92
|
+
// avoiding macOS /private/var vs /var confusion when the caller passed a non-realpath'd path).
|
|
93
|
+
async function repoMeta(realDir, displayPath) {
|
|
94
|
+
const p = displayPath ?? realDir;
|
|
95
|
+
let branch = 'HEAD';
|
|
96
|
+
try {
|
|
97
|
+
branch = (await git(realDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
98
|
+
}
|
|
99
|
+
catch { /* empty repo */ }
|
|
100
|
+
let dirty = false;
|
|
101
|
+
try {
|
|
102
|
+
dirty = (await git(realDir, ['status', '--porcelain'])).trim().length > 0;
|
|
103
|
+
}
|
|
104
|
+
catch { /* ignore */ }
|
|
105
|
+
return { name: basename(p), path: p, branch, dirty };
|
|
106
|
+
}
|
|
107
|
+
async function detectRepos(rawDir) {
|
|
108
|
+
const r = await resolveRepo(rawDir);
|
|
109
|
+
if ('error' in r)
|
|
110
|
+
return r;
|
|
111
|
+
if (await isRepo(r.real))
|
|
112
|
+
return { repos: [await repoMeta(r.real, rawDir)] };
|
|
113
|
+
const repos = [];
|
|
114
|
+
let dirents = [];
|
|
115
|
+
try {
|
|
116
|
+
dirents = await fs.readdir(r.real, { withFileTypes: true });
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
119
|
+
for (const d of dirents) {
|
|
120
|
+
if (!d.isDirectory())
|
|
121
|
+
continue;
|
|
122
|
+
const child = join(r.real, d.name);
|
|
123
|
+
const childDisplay = join(rawDir, d.name);
|
|
124
|
+
if (await isRepo(child))
|
|
125
|
+
repos.push(await repoMeta(child, childDisplay));
|
|
126
|
+
}
|
|
127
|
+
repos.sort((a, b) => a.name.localeCompare(b.name));
|
|
128
|
+
return { repos };
|
|
129
|
+
}
|
|
130
|
+
async function status(rawRepo) {
|
|
131
|
+
const r = await resolveRepo(rawRepo);
|
|
132
|
+
if ('error' in r)
|
|
133
|
+
return r;
|
|
134
|
+
if (!(await isRepo(r.real)))
|
|
135
|
+
return { error: 'not a repo', status: 400 };
|
|
136
|
+
let out;
|
|
137
|
+
try {
|
|
138
|
+
out = await git(r.real, ['status', '--porcelain']);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { error: 'git error', status: 500 };
|
|
142
|
+
}
|
|
143
|
+
const changes = out.split('\n').filter(Boolean).map((line) => {
|
|
144
|
+
const x = line[0] ?? '', y = line[1] ?? '';
|
|
145
|
+
let path = line.slice(3);
|
|
146
|
+
const arrow = path.indexOf(' -> ');
|
|
147
|
+
if (arrow >= 0)
|
|
148
|
+
path = path.slice(arrow + 4);
|
|
149
|
+
return { x: x === ' ' ? '' : x, y: y === ' ' ? '' : y, path };
|
|
150
|
+
});
|
|
151
|
+
return { changes };
|
|
152
|
+
}
|
|
153
|
+
const SEP = '\x1f';
|
|
154
|
+
// 分支 / ref 名校验:挡选项注入(开头 '-')、路径穿越('..')、NUL,并限定到安全字符子集。
|
|
155
|
+
// 只读 git log 用,失败时 git 自己会再拒一次。
|
|
156
|
+
function safeRef(ref) {
|
|
157
|
+
if (typeof ref !== 'string' || !ref)
|
|
158
|
+
return null;
|
|
159
|
+
if (ref[0] === '-' || ref.includes('..') || ref.includes('\0'))
|
|
160
|
+
return null;
|
|
161
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(ref))
|
|
162
|
+
return null;
|
|
163
|
+
return ref;
|
|
164
|
+
}
|
|
165
|
+
async function log(rawRepo, limit = 50, ref) {
|
|
166
|
+
const r = await resolveRepo(rawRepo);
|
|
167
|
+
if ('error' in r)
|
|
168
|
+
return r;
|
|
169
|
+
if (!(await isRepo(r.real)))
|
|
170
|
+
return { error: 'not a repo', status: 400 };
|
|
171
|
+
const n = Math.max(1, Math.min(500, Number(limit) || 50));
|
|
172
|
+
const safeR = ref == null || ref === '' ? null : safeRef(ref);
|
|
173
|
+
if (ref && !safeR)
|
|
174
|
+
return { error: 'bad ref', status: 400 };
|
|
175
|
+
const args = ['log', `-n${n}`, `--pretty=format:%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`];
|
|
176
|
+
if (safeR)
|
|
177
|
+
args.push(safeR); // git log <ref> …(指定分支只读看历史,不动工作树)
|
|
178
|
+
let out = '';
|
|
179
|
+
try {
|
|
180
|
+
out = await git(r.real, args);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { commits: [] };
|
|
184
|
+
} // 空仓库(无提交)/ 无此 ref → 空列表
|
|
185
|
+
const commits = out.split('\n').filter(Boolean).map((line) => {
|
|
186
|
+
const [hash = '', short = '', subject = '', author = '', relDate = ''] = line.split(SEP);
|
|
187
|
+
return { hash, short, subject, author, relDate };
|
|
188
|
+
});
|
|
189
|
+
return { commits };
|
|
190
|
+
}
|
|
191
|
+
async function branches(rawRepo) {
|
|
192
|
+
const r = await resolveRepo(rawRepo);
|
|
193
|
+
if ('error' in r)
|
|
194
|
+
return r;
|
|
195
|
+
if (!(await isRepo(r.real)))
|
|
196
|
+
return { error: 'not a repo', status: 400 };
|
|
197
|
+
const fmt = ['%(refname:short)', '%(HEAD)', '%(upstream:short)', '%(upstream:track)'].join(SEP);
|
|
198
|
+
let out;
|
|
199
|
+
try {
|
|
200
|
+
out = await git(r.real, ['for-each-ref', `--format=${fmt}`, 'refs/heads']);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return { branches: [] };
|
|
204
|
+
}
|
|
205
|
+
const branches = out.split('\n').filter(Boolean).map((line) => {
|
|
206
|
+
const [name = '', head = '', upstream = '', track = ''] = line.split(SEP);
|
|
207
|
+
const ahead = Number((track.match(/ahead (\d+)/) || [])[1] || 0);
|
|
208
|
+
const behind = Number((track.match(/behind (\d+)/) || [])[1] || 0);
|
|
209
|
+
return { name, current: head === '*', upstream: upstream || null, ahead, behind };
|
|
210
|
+
});
|
|
211
|
+
return { branches };
|
|
212
|
+
}
|
|
213
|
+
// 文件路径校验:相对、非绝对、不以 '-' 开头(防选项注入)、无 '..' 段、无 NUL。
|
|
214
|
+
function safeRelPath(p) {
|
|
215
|
+
if (typeof p !== 'string' || !p || p[0] === '-' || isAbsolute(p))
|
|
216
|
+
return null;
|
|
217
|
+
if (p.includes('\0') || p.split('/').some((seg) => seg === '..'))
|
|
218
|
+
return null;
|
|
219
|
+
return p;
|
|
220
|
+
}
|
|
221
|
+
const MAX_DIFF_BYTES = 512 * 1024;
|
|
222
|
+
function cap(text) {
|
|
223
|
+
if (text.length <= MAX_DIFF_BYTES)
|
|
224
|
+
return { diff: text, truncated: false };
|
|
225
|
+
return { diff: text.slice(0, MAX_DIFF_BYTES), truncated: true };
|
|
226
|
+
}
|
|
227
|
+
// diff 语义:仅 path → 工作区 vs HEAD;staged → 暂存区 vs HEAD;commit → 该提交 vs 其父。
|
|
228
|
+
async function diff(rawRepo, { path, commit, staged } = {}) {
|
|
229
|
+
const r = await resolveRepo(rawRepo);
|
|
230
|
+
if ('error' in r)
|
|
231
|
+
return r;
|
|
232
|
+
if (!(await isRepo(r.real)))
|
|
233
|
+
return { error: 'not a repo', status: 400 };
|
|
234
|
+
const rel = safeRelPath(path);
|
|
235
|
+
if (!rel)
|
|
236
|
+
return { error: 'bad path', status: 400 };
|
|
237
|
+
let args;
|
|
238
|
+
if (commit) {
|
|
239
|
+
if (typeof commit !== 'string' || !/^[0-9a-fA-F]{4,40}$/.test(commit))
|
|
240
|
+
return { error: 'bad commit', status: 400 };
|
|
241
|
+
args = ['show', '--format=', commit, '--', rel];
|
|
242
|
+
}
|
|
243
|
+
else if (staged) {
|
|
244
|
+
args = ['diff', '--staged', '--', rel];
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
args = ['diff', 'HEAD', '--', rel];
|
|
248
|
+
}
|
|
249
|
+
let out = '';
|
|
250
|
+
try {
|
|
251
|
+
out = await git(r.real, args);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
return { error: 'diff failed', status: 500 };
|
|
255
|
+
}
|
|
256
|
+
return cap(out);
|
|
257
|
+
}
|
|
258
|
+
async function commit(rawRepo, hash) {
|
|
259
|
+
const r = await resolveRepo(rawRepo);
|
|
260
|
+
if ('error' in r)
|
|
261
|
+
return r;
|
|
262
|
+
if (!(await isRepo(r.real)))
|
|
263
|
+
return { error: 'not a repo', status: 400 };
|
|
264
|
+
if (typeof hash !== 'string' || !/^[0-9a-fA-F]{4,40}$/.test(hash))
|
|
265
|
+
return { error: 'bad commit', status: 400 };
|
|
266
|
+
let message;
|
|
267
|
+
let ns;
|
|
268
|
+
try {
|
|
269
|
+
message = (await git(r.real, ['show', '-s', '--format=%B', hash])).trim();
|
|
270
|
+
// --root 让首次提交(无父)也能列出文件。
|
|
271
|
+
ns = await git(r.real, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return { error: 'git error', status: 500 };
|
|
275
|
+
}
|
|
276
|
+
const files = ns.split('\n').filter(Boolean).map((line) => {
|
|
277
|
+
const [code = '', ...rest] = line.split('\t');
|
|
278
|
+
return { x: code[0] ?? '', y: '', path: rest[rest.length - 1] ?? '' };
|
|
279
|
+
}).filter((file) => file.path);
|
|
280
|
+
return { message, files };
|
|
281
|
+
}
|
|
282
|
+
return { resolveRepo, isRepo, worktree, detectRepos, status, log, branches, diff, commit };
|
|
283
|
+
}
|
|
284
|
+
export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const SUBSYSTEM_NAMES = ['workspace', 'codex', 'browser'];
|
|
2
|
+
const SUBSYSTEM_STATUSES = ['starting', 'ready', 'degraded', 'disabled'];
|
|
3
|
+
const READY_STATUSES = ['starting', 'ready', 'degraded'];
|
|
4
|
+
function recordOf(value) {
|
|
5
|
+
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
6
|
+
? value
|
|
7
|
+
: null;
|
|
8
|
+
}
|
|
9
|
+
function parseSubsystem(value) {
|
|
10
|
+
const record = recordOf(value);
|
|
11
|
+
if (!record || !SUBSYSTEM_STATUSES.includes(record.status)
|
|
12
|
+
|| typeof record.required !== 'boolean'
|
|
13
|
+
|| (record.detail !== null && (typeof record.detail !== 'string' || !record.detail)))
|
|
14
|
+
return null;
|
|
15
|
+
if (record.required && record.status === 'disabled')
|
|
16
|
+
return null;
|
|
17
|
+
return {
|
|
18
|
+
status: record.status,
|
|
19
|
+
required: record.required,
|
|
20
|
+
detail: record.detail,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function parseHealthReadySnapshot(value) {
|
|
24
|
+
const record = recordOf(value);
|
|
25
|
+
const subsystemsValue = recordOf(record?.subsystems);
|
|
26
|
+
if (!record || !subsystemsValue || !READY_STATUSES.includes(record.status)
|
|
27
|
+
|| typeof record.ready !== 'boolean' || typeof record.checkedAt !== 'number'
|
|
28
|
+
|| !Number.isFinite(record.checkedAt))
|
|
29
|
+
return null;
|
|
30
|
+
const entries = SUBSYSTEM_NAMES.map((name) => [name, parseSubsystem(subsystemsValue[name])]);
|
|
31
|
+
if (entries.some(([, subsystem]) => !subsystem))
|
|
32
|
+
return null;
|
|
33
|
+
const subsystems = Object.fromEntries(entries);
|
|
34
|
+
const computedReady = Object.values(subsystems)
|
|
35
|
+
.every((subsystem) => !subsystem.required || subsystem.status === 'ready');
|
|
36
|
+
if (computedReady !== record.ready)
|
|
37
|
+
return null;
|
|
38
|
+
const requiredDegraded = Object.values(subsystems)
|
|
39
|
+
.some((subsystem) => subsystem.required && subsystem.status === 'degraded');
|
|
40
|
+
const anyDegraded = Object.values(subsystems).some((subsystem) => subsystem.status === 'degraded');
|
|
41
|
+
const computedStatus = computedReady
|
|
42
|
+
? (anyDegraded ? 'degraded' : 'ready')
|
|
43
|
+
: (requiredDegraded ? 'degraded' : 'starting');
|
|
44
|
+
if (computedStatus !== record.status)
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
status: computedStatus,
|
|
48
|
+
ready: computedReady,
|
|
49
|
+
checkedAt: record.checkedAt,
|
|
50
|
+
subsystems,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function parseHealthLiveSnapshot(value) {
|
|
54
|
+
const record = recordOf(value);
|
|
55
|
+
return record?.status === 'live' && record.live === true
|
|
56
|
+
&& typeof record.checkedAt === 'number' && Number.isFinite(record.checkedAt)
|
|
57
|
+
? { status: 'live', live: true, checkedAt: record.checkedAt }
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
export class RuntimeHealth {
|
|
61
|
+
#subsystems;
|
|
62
|
+
#now;
|
|
63
|
+
constructor({ browserRequired = false, now = Date.now } = {}) {
|
|
64
|
+
this.#now = now;
|
|
65
|
+
this.#subsystems = {
|
|
66
|
+
workspace: { status: 'starting', required: true, detail: null },
|
|
67
|
+
codex: { status: 'starting', required: true, detail: null },
|
|
68
|
+
browser: browserRequired
|
|
69
|
+
? { status: 'starting', required: true, detail: null }
|
|
70
|
+
: { status: 'disabled', required: false, detail: null },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
set(name, status, detail = null) {
|
|
74
|
+
if (!SUBSYSTEM_NAMES.includes(name) || !SUBSYSTEM_STATUSES.includes(status)
|
|
75
|
+
|| (detail !== null && !detail))
|
|
76
|
+
throw new Error('invalid health subsystem state');
|
|
77
|
+
const current = this.#subsystems[name];
|
|
78
|
+
if (current.required && status === 'disabled')
|
|
79
|
+
throw new Error(`${name} is required`);
|
|
80
|
+
this.#subsystems[name] = { status, required: current.required, detail };
|
|
81
|
+
}
|
|
82
|
+
live() {
|
|
83
|
+
return { status: 'live', live: true, checkedAt: this.#now() };
|
|
84
|
+
}
|
|
85
|
+
snapshot() {
|
|
86
|
+
const subsystems = structuredClone(this.#subsystems);
|
|
87
|
+
const ready = Object.values(subsystems)
|
|
88
|
+
.every((subsystem) => !subsystem.required || subsystem.status === 'ready');
|
|
89
|
+
const requiredDegraded = Object.values(subsystems)
|
|
90
|
+
.some((subsystem) => subsystem.required && subsystem.status === 'degraded');
|
|
91
|
+
const anyDegraded = Object.values(subsystems).some((subsystem) => subsystem.status === 'degraded');
|
|
92
|
+
const status = ready
|
|
93
|
+
? (anyDegraded ? 'degraded' : 'ready')
|
|
94
|
+
: (requiredDegraded ? 'degraded' : 'starting');
|
|
95
|
+
return { status, ready, checkedAt: this.#now(), subsystems };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { bearerFrom, expressAuth, tokenEquals } 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 { DEFAULT_SHORTCUTS } from './shortcutConfig.js';
|
|
25
|
+
import { workspaceRoutes } from './routes/workspace.js';
|
|
26
|
+
import { browserRoutes } from './browser/routes.js';
|
|
27
|
+
import { apiErrorBoundary, apiNotFound, apiRequestContext } from './apiErrors.js';
|
|
28
|
+
import { agentRoutes } from './routes/agents.js';
|
|
29
|
+
import { projectTaskRoutes } from './projectTask/routes.js';
|
|
30
|
+
import { apiAccountRoutes } from './routes/apiAccounts.js';
|
|
31
|
+
// Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
|
|
32
|
+
export { isAllowedKey } from './routes/terminal.js';
|
|
33
|
+
export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
|
|
34
|
+
const r = express.Router();
|
|
35
|
+
r.use(apiRequestContext(apiErrors));
|
|
36
|
+
// Account responses, including auth failures and malformed JSON, must never be cached. Keep the
|
|
37
|
+
// generic auth response stable for every existing route while giving this new API its coded envelope.
|
|
38
|
+
if (apiAccounts) {
|
|
39
|
+
r.use('/api-accounts', (req, res, next) => {
|
|
40
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
41
|
+
const provided = bearerFrom(req.get('authorization'));
|
|
42
|
+
if (provided && tokenEquals(provided, token))
|
|
43
|
+
next();
|
|
44
|
+
else
|
|
45
|
+
res.status(401).json({
|
|
46
|
+
error: 'unauthorized', code: 'unauthorized', requestId: res.locals.requestId,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
r.use(expressAuth(token));
|
|
51
|
+
r.use(express.json());
|
|
52
|
+
const eventOptions = { commands, push };
|
|
53
|
+
const claudeEvents = events || createClaudeEvents(eventOptions);
|
|
54
|
+
const legacyAgentIdentity = {
|
|
55
|
+
identifyPanes: (panes) => (claudeEvents.identifyPaneAgents(panes.map((pane) => ({
|
|
56
|
+
id: pane.paneId,
|
|
57
|
+
command: pane.currentCommand,
|
|
58
|
+
tty: pane.tty ?? '',
|
|
59
|
+
})))),
|
|
60
|
+
};
|
|
61
|
+
const deps = {
|
|
62
|
+
token, commands, docs, git, push, notifications, claudeEvents,
|
|
63
|
+
agentIdentity: agentRuntime ?? legacyAgentIdentity,
|
|
64
|
+
uploadExts, maxUploadBytes, asrEnv, shortcuts, home, stateFile,
|
|
65
|
+
...(agentRuntime !== undefined ? { agentRuntime } : {}),
|
|
66
|
+
...(previews !== undefined ? { previews } : {}),
|
|
67
|
+
...(workspace !== undefined ? { workspace } : {}),
|
|
68
|
+
...(browser !== undefined ? { browser } : {}),
|
|
69
|
+
...(browserBootstrap !== undefined ? { browserBootstrap } : {}),
|
|
70
|
+
...(previewDomain !== undefined ? { previewDomain } : {}),
|
|
71
|
+
...(agentIntegrationContext !== undefined ? { agentIntegrationContext } : {}),
|
|
72
|
+
};
|
|
73
|
+
r.use(sessionRoutes(deps));
|
|
74
|
+
r.use(terminalRoutes(deps));
|
|
75
|
+
r.use(gitRoutes(deps));
|
|
76
|
+
r.use(fileRoutes(deps));
|
|
77
|
+
r.use(pushRoutes(deps));
|
|
78
|
+
r.use(notificationRoutes(deps));
|
|
79
|
+
r.use(systemRoutes(deps));
|
|
80
|
+
if (apiAccounts)
|
|
81
|
+
r.use(apiAccountRoutes({ service: apiAccounts }));
|
|
82
|
+
r.use(previewRoutes(deps));
|
|
83
|
+
// In the main process browser APIs are forwarded to the isolated worker before this router. Keep
|
|
84
|
+
// preview-domain wiring out of this unused fallback route so capability reporting does not require
|
|
85
|
+
// the worker-owned bootstrap ticket store.
|
|
86
|
+
r.use('/browser-proxy', browserRoutes(browser ? deps : { ...deps, previewDomain: null }));
|
|
87
|
+
if (agentRuntime)
|
|
88
|
+
r.use(agentRoutes({ runtime: agentRuntime }));
|
|
89
|
+
if (projectTask)
|
|
90
|
+
r.use(projectTaskRoutes({ runtime: projectTask }));
|
|
91
|
+
if (workspace)
|
|
92
|
+
r.use(workspaceRoutes({ workspace }));
|
|
93
|
+
r.use(apiNotFound(apiErrors));
|
|
94
|
+
r.use(apiErrorBoundary(apiErrors));
|
|
95
|
+
return r;
|
|
96
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Shared JSON-store persistence for small single-writer runtime registries (push subscriptions, previews,
|
|
2
|
+
// and notification inboxes). The PrivateStateStore boundary supplies 0700/0600 permission migration,
|
|
3
|
+
// unique sibling temporaries, fsync, and atomic rename. Callers intentionally keep best-effort semantics:
|
|
4
|
+
// a failed flush is recoverable through device re-registration or lease renewal.
|
|
5
|
+
import { PrivateStateStore } from './privateStateStore.js';
|
|
6
|
+
export function readJsonArray(file) {
|
|
7
|
+
const value = new PrivateStateStore(file).read();
|
|
8
|
+
return Array.isArray(value) ? value : [];
|
|
9
|
+
}
|
|
10
|
+
export function writeJsonAtomic(file, value) {
|
|
11
|
+
try {
|
|
12
|
+
new PrivateStateStore(file).write(value);
|
|
13
|
+
}
|
|
14
|
+
catch { /* best effort — a lost flush is recoverable by the caller */ }
|
|
15
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
|
|
4
4
|
const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
|
|
5
5
|
const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
|
|
6
|
-
|
|
7
6
|
export function isAllowedKey(key) {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if (typeof key !== 'string')
|
|
8
|
+
return false;
|
|
9
|
+
return NAMED_KEY.test(key) || (CHAR_KEY.test(key) && key.includes('-'));
|
|
10
10
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
|
|
14
|
+
const CAP = 100;
|
|
15
|
+
const genId = () => crypto.randomBytes(9).toString('base64url');
|
|
16
|
+
const DELIVERY_STATUSES = new Set(['pending', 'success', 'failed']);
|
|
17
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
function parseDelivery(value) {
|
|
19
|
+
if (!isRecord(value) || typeof value.status !== 'string' || !DELIVERY_STATUSES.has(value.status)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const delivery = { status: value.status };
|
|
23
|
+
if (delivery.status === 'failed' && typeof value.reason === 'string')
|
|
24
|
+
delivery.reason = value.reason;
|
|
25
|
+
return delivery;
|
|
26
|
+
}
|
|
27
|
+
function parseStoredNotification(value) {
|
|
28
|
+
if (!isRecord(value)
|
|
29
|
+
|| typeof value.id !== 'string'
|
|
30
|
+
|| typeof value.ts !== 'number'
|
|
31
|
+
|| !Number.isFinite(value.ts)
|
|
32
|
+
|| typeof value.title !== 'string'
|
|
33
|
+
|| typeof value.body !== 'string')
|
|
34
|
+
return null;
|
|
35
|
+
const notification = {
|
|
36
|
+
id: value.id,
|
|
37
|
+
ts: value.ts,
|
|
38
|
+
title: value.title,
|
|
39
|
+
body: value.body,
|
|
40
|
+
};
|
|
41
|
+
if (typeof value.tag === 'string')
|
|
42
|
+
notification.tag = value.tag;
|
|
43
|
+
if (typeof value.url === 'string') {
|
|
44
|
+
const safeUrl = sanitizeNotificationUrl(value.url);
|
|
45
|
+
if (safeUrl)
|
|
46
|
+
notification.url = safeUrl;
|
|
47
|
+
}
|
|
48
|
+
const delivery = parseDelivery(value.delivery);
|
|
49
|
+
if (delivery)
|
|
50
|
+
notification.delivery = delivery;
|
|
51
|
+
return notification;
|
|
52
|
+
}
|
|
53
|
+
// pushKey is base64url already; sanitize anyway so a hostile value can't escape DIR. Empty → null (skip).
|
|
54
|
+
function fileFor(key) {
|
|
55
|
+
const safe = String(key || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
56
|
+
return safe ? path.join(DIR, `${safe}.json`) : null;
|
|
57
|
+
}
|
|
58
|
+
const load = (file) => readJsonArray(file)
|
|
59
|
+
.map(parseStoredNotification)
|
|
60
|
+
.filter((notification) => notification !== null);
|
|
61
|
+
export function record(pushKeys, input = {}) {
|
|
62
|
+
const { title, body, tag, url } = input;
|
|
63
|
+
const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
|
|
64
|
+
if (tag)
|
|
65
|
+
rec.tag = String(tag);
|
|
66
|
+
const initialDelivery = parseDelivery(input.delivery);
|
|
67
|
+
if (initialDelivery?.status === 'pending')
|
|
68
|
+
rec.delivery = { status: 'pending' };
|
|
69
|
+
const safeUrl = sanitizeNotificationUrl(url);
|
|
70
|
+
if (safeUrl)
|
|
71
|
+
rec.url = safeUrl;
|
|
72
|
+
for (const key of pushKeys || []) {
|
|
73
|
+
const file = fileFor(key);
|
|
74
|
+
if (!file)
|
|
75
|
+
continue;
|
|
76
|
+
const items = load(file);
|
|
77
|
+
items.push(rec);
|
|
78
|
+
writeJsonAtomic(file, items.length > CAP ? items.slice(items.length - CAP) : items);
|
|
79
|
+
}
|
|
80
|
+
return rec;
|
|
81
|
+
}
|
|
82
|
+
export function updateDelivery(pushKey, id, delivery) {
|
|
83
|
+
const file = fileFor(pushKey);
|
|
84
|
+
if (!file)
|
|
85
|
+
return false;
|
|
86
|
+
const items = load(file);
|
|
87
|
+
const item = items.find((n) => n.id === id);
|
|
88
|
+
if (!item)
|
|
89
|
+
return false;
|
|
90
|
+
const nextDelivery = parseDelivery(delivery);
|
|
91
|
+
if (!nextDelivery)
|
|
92
|
+
return false;
|
|
93
|
+
item.delivery = nextDelivery;
|
|
94
|
+
writeJsonAtomic(file, items);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
export function list(pushKey) {
|
|
98
|
+
const file = fileFor(pushKey);
|
|
99
|
+
return file ? load(file).reverse() : [];
|
|
100
|
+
}
|
|
101
|
+
export function remove(pushKey, id) {
|
|
102
|
+
const file = fileFor(pushKey);
|
|
103
|
+
if (!file)
|
|
104
|
+
return false;
|
|
105
|
+
const items = load(file);
|
|
106
|
+
const kept = items.filter((n) => n.id !== id);
|
|
107
|
+
if (kept.length === items.length)
|
|
108
|
+
return false;
|
|
109
|
+
writeJsonAtomic(file, kept);
|
|
110
|
+
return true;
|
|
111
|
+
}
|