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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Pure CLI option handling: a tiny hand-rolled flag parser (no dependency) + config resolution.
|
|
2
|
+
// Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
|
|
3
|
+
// (generated when unset) so a public tunnel can never come up token-less.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
6
|
+
export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'];
|
|
7
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
const isTunnel = (value) => typeof value === 'string' && TUNNELS.includes(value);
|
|
9
|
+
const camel = (value) => value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
|
|
10
|
+
// argv = process.argv.slice(2). `command` is the first bare word; flags are `--key value`,
|
|
11
|
+
// `--key` (boolean true), `--no-key` (boolean false), and `-f` (alias for --foreground).
|
|
12
|
+
export function parseArgs(argv) {
|
|
13
|
+
const [command = 'help', ...rest] = argv;
|
|
14
|
+
const flags = {};
|
|
15
|
+
const positionals = [];
|
|
16
|
+
const unknownShortFlags = [];
|
|
17
|
+
const multiFlags = new Set(['session']);
|
|
18
|
+
const assign = (key, value) => {
|
|
19
|
+
if (!multiFlags.has(key) || flags[key] === undefined) {
|
|
20
|
+
flags[key] = value;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
|
|
24
|
+
};
|
|
25
|
+
for (let i = 0; i < rest.length; i++) {
|
|
26
|
+
const a = rest[i];
|
|
27
|
+
if (a === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
if (a === '-f') {
|
|
30
|
+
flags.foreground = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!a.startsWith('--')) {
|
|
34
|
+
if (a.startsWith('-'))
|
|
35
|
+
unknownShortFlags.push(a);
|
|
36
|
+
else
|
|
37
|
+
positionals.push(a);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const key = a.slice(2);
|
|
41
|
+
if (key.startsWith('no-')) {
|
|
42
|
+
assign(camel(key.slice(3)), false);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const next = rest[i + 1];
|
|
46
|
+
if (next === undefined || next.startsWith('--')) {
|
|
47
|
+
assign(camel(key), true);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
assign(camel(key), next);
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
command,
|
|
56
|
+
flags,
|
|
57
|
+
...(positionals.length ? { positionals } : {}),
|
|
58
|
+
...(unknownShortFlags.length ? { unknownShortFlags } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ssh 回退公网地址:SSH 不生成 URL,无 --public-url 时用 host:remotePort(去掉 user@ 与 :sshPort)。
|
|
62
|
+
export function sshPublicFallback(sshHost, remotePort) {
|
|
63
|
+
const host = sshHost.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
|
|
64
|
+
return `http://${host}:${remotePort}`;
|
|
65
|
+
}
|
|
66
|
+
export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen) {
|
|
67
|
+
const pick = (key, ...fallbacks) => {
|
|
68
|
+
for (const v of [flags[key], fileCfg[key], ...fallbacks])
|
|
69
|
+
if (v !== undefined && v !== null)
|
|
70
|
+
return v;
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
73
|
+
const tunnel = pick('tunnel', 'none');
|
|
74
|
+
if (!isTunnel(tunnel))
|
|
75
|
+
throw new Error(`unknown tunnel: ${String(tunnel)} (use: ${TUNNELS.join(', ')})`);
|
|
76
|
+
const port = Number(pick('port', env.HANDMUX_PORT, 19999));
|
|
77
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
78
|
+
throw new Error(`bad port: ${pick('port', env.HANDMUX_PORT, 19999)}`);
|
|
79
|
+
const cfg = {
|
|
80
|
+
tunnel,
|
|
81
|
+
port,
|
|
82
|
+
name: optionalString(pick('name', env.HANDMUX_APP_NAME), 'name'),
|
|
83
|
+
host: optionalString(pick('host', env.HANDMUX_HOST, '0.0.0.0'), 'host') ?? '0.0.0.0',
|
|
84
|
+
token: optionalString(pick('token', env.HANDMUX_TOKEN), 'token') ?? gen(),
|
|
85
|
+
foreground: !!pick('foreground', false),
|
|
86
|
+
qr: pick('qr', true) !== false,
|
|
87
|
+
// Unified config — what used to live in .env. The supervisor injects these into the server child's
|
|
88
|
+
// environment (HANDMUX_STATIC_DIR / VAPID_* / XFYUN_* …), which is exactly where the server reads them.
|
|
89
|
+
staticDir: optionalString(pick('staticDir', env.HANDMUX_STATIC_DIR), 'static-dir'),
|
|
90
|
+
uploadExts: optionalString(pick('uploadExts', env.HANDMUX_UPLOAD_EXTS), 'upload-exts'),
|
|
91
|
+
previewDomain: optionalString(pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN), 'preview-domain'),
|
|
92
|
+
vapid: parseVapid(fileCfg.vapid), // { public, private, subject } — push notifications
|
|
93
|
+
xfyun: parseXfyun(fileCfg.xfyun), // { appId, apiKey, apiSecret } — voice input
|
|
94
|
+
shortcuts: normalizeShortcuts(fileCfg.shortcuts),
|
|
95
|
+
// An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
|
|
96
|
+
// own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
|
|
97
|
+
// tunnel-specific blocks below only fill a *fallback* when it wasn't given.
|
|
98
|
+
//
|
|
99
|
+
// Guard: a publicUrl in the FILE was set for the file's tunnel, so it only carries over when the
|
|
100
|
+
// resolved tunnel still matches (or the file pins no tunnel). Otherwise a `--tunnel B` override would
|
|
101
|
+
// advertise tunnel A's URL. A flag/env publicUrl is explicit for THIS run and always wins.
|
|
102
|
+
publicUrl: optionalString(resolvePublicUrl(flags, fileCfg, env, tunnel), 'public-url'),
|
|
103
|
+
// tunnel-specific (null unless the relevant tunnel is selected)
|
|
104
|
+
sshHost: null, remotePort: null, sshJump: null,
|
|
105
|
+
cfHostname: null, cfTunnelName: null,
|
|
106
|
+
authtoken: null, cpolarRegion: null,
|
|
107
|
+
};
|
|
108
|
+
if (tunnel === 'ssh') {
|
|
109
|
+
cfg.sshHost = optionalString(pick('sshHost', env.HANDMUX_SSH_HOST), 'ssh-host');
|
|
110
|
+
if (!cfg.sshHost)
|
|
111
|
+
throw new Error('ssh tunnel needs --ssh-host user@host (or HANDMUX_SSH_HOST)');
|
|
112
|
+
const rp = Number(pick('remotePort', env.HANDMUX_REMOTE_PORT, port));
|
|
113
|
+
if (!Number.isInteger(rp) || rp < 1 || rp > 65535)
|
|
114
|
+
throw new Error(`bad remote-port: ${pick('remotePort', env.HANDMUX_REMOTE_PORT, port)}`);
|
|
115
|
+
cfg.remotePort = rp;
|
|
116
|
+
cfg.sshJump = optionalString(pick('sshJump', env.HANDMUX_SSH_JUMP), 'ssh-jump');
|
|
117
|
+
cfg.publicUrl = cfg.publicUrl || sshPublicFallback(cfg.sshHost, rp);
|
|
118
|
+
}
|
|
119
|
+
if (tunnel === 'cloudflare-named') {
|
|
120
|
+
cfg.cfHostname = optionalString(pick('cfHostname', env.HANDMUX_CF_HOSTNAME), 'cf-hostname');
|
|
121
|
+
if (!cfg.cfHostname)
|
|
122
|
+
throw new Error('cloudflare-named needs --cf-hostname handmux.example.com (or HANDMUX_CF_HOSTNAME)');
|
|
123
|
+
cfg.cfTunnelName = optionalString(pick('cfTunnelName', env.HANDMUX_CF_TUNNEL_NAME), 'cf-tunnel-name') || 'handmux';
|
|
124
|
+
cfg.publicUrl = cfg.publicUrl || `https://${cfg.cfHostname}`;
|
|
125
|
+
}
|
|
126
|
+
// natapp / cpolar: one shared credential (--authtoken), and named/fixed mode is just --public-url (the
|
|
127
|
+
// reserved domain) — no separate concept. A bare host is normalised to https:// so the user can paste the
|
|
128
|
+
// domain the provider gave them. cpolar also takes an optional edge --cpolar-region (cn = mainland China).
|
|
129
|
+
if (tunnel === 'natapp' || tunnel === 'cpolar') {
|
|
130
|
+
cfg.authtoken = optionalString(pick('authtoken', env.HANDMUX_AUTHTOKEN), 'authtoken');
|
|
131
|
+
if (!cfg.authtoken) {
|
|
132
|
+
const where = tunnel === 'natapp' ? 'natapp.cn' : 'cpolar.com';
|
|
133
|
+
throw new Error(`${tunnel} needs --authtoken <token> (or HANDMUX_AUTHTOKEN) — get it from ${where} after logging in`);
|
|
134
|
+
}
|
|
135
|
+
if (cfg.publicUrl && !/^https?:\/\//i.test(cfg.publicUrl))
|
|
136
|
+
cfg.publicUrl = `https://${cfg.publicUrl}`;
|
|
137
|
+
if (tunnel === 'cpolar')
|
|
138
|
+
cfg.cpolarRegion = optionalString(pick('cpolarRegion', env.HANDMUX_CPOLAR_REGION), 'cpolar-region');
|
|
139
|
+
}
|
|
140
|
+
return cfg;
|
|
141
|
+
}
|
|
142
|
+
// publicUrl resolution with the cross-tunnel guard (see resolveConfig). flag > file(if tunnel matches) > env.
|
|
143
|
+
function optionalString(value, name) {
|
|
144
|
+
if (value === undefined || value === null || value === '')
|
|
145
|
+
return null;
|
|
146
|
+
if (typeof value !== 'string')
|
|
147
|
+
throw new Error(`bad ${name}: expected text`);
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
function stringFields(value, keys) {
|
|
151
|
+
if (!isRecord(value))
|
|
152
|
+
return null;
|
|
153
|
+
const result = {};
|
|
154
|
+
for (const key of keys)
|
|
155
|
+
if (typeof value[key] === 'string')
|
|
156
|
+
result[key] = value[key];
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function parseVapid(value) {
|
|
160
|
+
return stringFields(value, ['public', 'private', 'subject']);
|
|
161
|
+
}
|
|
162
|
+
function parseXfyun(value) {
|
|
163
|
+
return stringFields(value, ['appId', 'apiKey', 'apiSecret']);
|
|
164
|
+
}
|
|
165
|
+
function resolvePublicUrl(flags, fileCfg, env, tunnel) {
|
|
166
|
+
if (flags.publicUrl != null)
|
|
167
|
+
return flags.publicUrl;
|
|
168
|
+
const fileTunnel = fileCfg.tunnel;
|
|
169
|
+
const fileApplies = fileTunnel == null || fileTunnel === tunnel;
|
|
170
|
+
if (fileApplies && fileCfg.publicUrl != null)
|
|
171
|
+
return fileCfg.publicUrl;
|
|
172
|
+
if (env.HANDMUX_PUBLIC_URL != null)
|
|
173
|
+
return env.HANDMUX_PUBLIC_URL;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
// Default token: 8 chars from a typing-friendly alphabet (lowercase + digits, look-alikes 0/o/1/l dropped)
|
|
177
|
+
// so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
|
|
178
|
+
// length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
|
|
179
|
+
const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
180
|
+
export function defaultGen() {
|
|
181
|
+
let s = '';
|
|
182
|
+
for (let i = 0; i < 8; i++)
|
|
183
|
+
s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
|
|
187
|
+
if (flags[key] != null)
|
|
188
|
+
return { value: flags[key], origin: 'flag' };
|
|
189
|
+
if (fileCfg[key] != null)
|
|
190
|
+
return { value: fileCfg[key], origin: cfgPath || 'file' };
|
|
191
|
+
if (envKey && env[envKey] != null)
|
|
192
|
+
return { value: env[envKey], origin: 'env' };
|
|
193
|
+
return { value: def, origin: 'default' };
|
|
194
|
+
}
|
|
195
|
+
// Build the rows for `handmux config`: the value each key WOULD resolve to plus where it came from.
|
|
196
|
+
// Lenient (never throws — unlike resolveConfig — so a half-finished config still prints) and secret-safe
|
|
197
|
+
// (token masked; push/voice shown only as on/off). Tunnel-specific rows appear only for the live tunnel.
|
|
198
|
+
export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env) {
|
|
199
|
+
const rows = [];
|
|
200
|
+
const mask = (value) => (String(value).length <= 8 ? '••••' : `••••${String(value).slice(-4)}`);
|
|
201
|
+
const add = (key, traced, display) => {
|
|
202
|
+
rows.push({ key, origin: traced.origin, display: display ?? String(traced.value) });
|
|
203
|
+
};
|
|
204
|
+
const tunnel = trace(flags, fileCfg, env, cfgPath, 'tunnel', null, 'none');
|
|
205
|
+
add('tunnel', tunnel);
|
|
206
|
+
add('port', trace(flags, fileCfg, env, cfgPath, 'port', 'HANDMUX_PORT', 19999));
|
|
207
|
+
add('host', trace(flags, fileCfg, env, cfgPath, 'host', 'HANDMUX_HOST', '0.0.0.0'));
|
|
208
|
+
const name = trace(flags, fileCfg, env, cfgPath, 'name', 'HANDMUX_APP_NAME', null);
|
|
209
|
+
add('name', name, name.value == null ? '(default)' : String(name.value));
|
|
210
|
+
const lang = trace(flags, fileCfg, env, cfgPath, 'lang', 'HANDMUX_LANG', null);
|
|
211
|
+
add('lang', lang, lang.value == null ? '(auto — shell locale)' : String(lang.value));
|
|
212
|
+
const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', null);
|
|
213
|
+
add('token', token, token.value == null ? '(generated each start)' : mask(token.value));
|
|
214
|
+
// publicUrl honours the same cross-tunnel guard as resolveConfig (file value only when tunnel matches).
|
|
215
|
+
const t = tunnel.value;
|
|
216
|
+
let pub = trace(flags, fileCfg, env, cfgPath, 'publicUrl', 'HANDMUX_PUBLIC_URL', null);
|
|
217
|
+
if (pub.origin !== 'flag' && pub.origin !== 'env' && fileCfg.tunnel != null && fileCfg.tunnel !== t) {
|
|
218
|
+
pub = { value: null, origin: 'default' };
|
|
219
|
+
}
|
|
220
|
+
add('publicUrl', pub, pub.value == null ? '(none — derived from tunnel if any)' : String(pub.value));
|
|
221
|
+
const previewDomain = trace(flags, fileCfg, env, cfgPath, 'previewDomain', 'HANDMUX_PREVIEW_DOMAIN', null);
|
|
222
|
+
add('previewDomain', previewDomain, previewDomain.value == null ? '(none)' : String(previewDomain.value));
|
|
223
|
+
if (t === 'ssh') {
|
|
224
|
+
add('sshHost', trace(flags, fileCfg, env, cfgPath, 'sshHost', 'HANDMUX_SSH_HOST', null));
|
|
225
|
+
add('remotePort', trace(flags, fileCfg, env, cfgPath, 'remotePort', 'HANDMUX_REMOTE_PORT', '(= port)'));
|
|
226
|
+
const jump = trace(flags, fileCfg, env, cfgPath, 'sshJump', 'HANDMUX_SSH_JUMP', null);
|
|
227
|
+
add('sshJump', jump, jump.value == null ? '(none)' : String(jump.value));
|
|
228
|
+
}
|
|
229
|
+
if (t === 'cloudflare-named') {
|
|
230
|
+
add('cfHostname', trace(flags, fileCfg, env, cfgPath, 'cfHostname', 'HANDMUX_CF_HOSTNAME', null));
|
|
231
|
+
add('cfTunnelName', trace(flags, fileCfg, env, cfgPath, 'cfTunnelName', 'HANDMUX_CF_TUNNEL_NAME', 'handmux'));
|
|
232
|
+
}
|
|
233
|
+
if (t === 'natapp' || t === 'cpolar') {
|
|
234
|
+
const at = trace(flags, fileCfg, env, cfgPath, 'authtoken', 'HANDMUX_AUTHTOKEN', null);
|
|
235
|
+
add('authtoken', at, at.value == null ? '(required — from the provider dashboard)' : mask(at.value));
|
|
236
|
+
}
|
|
237
|
+
if (t === 'cpolar') {
|
|
238
|
+
const rg = trace(flags, fileCfg, env, cfgPath, 'cpolarRegion', 'HANDMUX_CPOLAR_REGION', null);
|
|
239
|
+
add('cpolarRegion', rg, rg.value == null ? '(cpolar default)' : String(rg.value));
|
|
240
|
+
}
|
|
241
|
+
// Integrations live only in the file (secrets); show presence, never the keys.
|
|
242
|
+
rows.push({ key: 'push (vapid)', origin: fileCfg.vapid ? (cfgPath || 'file') : 'default', display: fileCfg.vapid ? 'on' : 'off' });
|
|
243
|
+
rows.push({ key: 'voice (xfyun)', origin: fileCfg.xfyun ? (cfgPath || 'file') : 'default', display: fileCfg.xfyun ? 'on' : 'off' });
|
|
244
|
+
return rows;
|
|
245
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
const MANAGED_MARK = '// handmux-managed-pi-extension:v1';
|
|
6
|
+
const ENTRY_MARK = '// handmux-entry:';
|
|
7
|
+
export class PiExtensionInstallError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = 'PiExtensionInstallError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function extensionDirectory(home) {
|
|
16
|
+
return path.join(home, '.pi', 'agent', 'extensions', 'handmux');
|
|
17
|
+
}
|
|
18
|
+
export function piExtensionFile(home = homedir()) {
|
|
19
|
+
return path.join(extensionDirectory(home), 'index.ts');
|
|
20
|
+
}
|
|
21
|
+
function errorCode(error) {
|
|
22
|
+
if (!error || typeof error !== 'object' || !('code' in error))
|
|
23
|
+
return null;
|
|
24
|
+
return typeof error.code === 'string' ? error.code : null;
|
|
25
|
+
}
|
|
26
|
+
function readManaged(file) {
|
|
27
|
+
try {
|
|
28
|
+
const stat = fs.lstatSync(file);
|
|
29
|
+
if (!stat.isFile())
|
|
30
|
+
return 'conflict';
|
|
31
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
32
|
+
const lines = content.split('\n');
|
|
33
|
+
if (lines[0] !== MANAGED_MARK || !lines[1]?.startsWith(ENTRY_MARK))
|
|
34
|
+
return 'conflict';
|
|
35
|
+
const entryUrl = lines[1].slice(ENTRY_MARK.length);
|
|
36
|
+
if (!entryUrl.startsWith('file:'))
|
|
37
|
+
return 'conflict';
|
|
38
|
+
try {
|
|
39
|
+
fileURLToPath(entryUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 'conflict';
|
|
43
|
+
}
|
|
44
|
+
return { content, entryUrl };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (errorCode(error) === 'ENOENT')
|
|
48
|
+
return null;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function expectedEntryUrl(entryFile) {
|
|
53
|
+
try {
|
|
54
|
+
return pathToFileURL(fs.realpathSync(entryFile)).href;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return pathToFileURL(path.resolve(entryFile)).href;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function wrapper(entryFile) {
|
|
61
|
+
const entryUrl = expectedEntryUrl(entryFile);
|
|
62
|
+
return [
|
|
63
|
+
MANAGED_MARK,
|
|
64
|
+
`${ENTRY_MARK}${entryUrl}`,
|
|
65
|
+
'// This tiny wrapper is owned by Handmux. Pi loads it from its documented global extension path.',
|
|
66
|
+
`export { default } from ${JSON.stringify(entryUrl)};`,
|
|
67
|
+
'',
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
|
70
|
+
function validateEntry(entryFile) {
|
|
71
|
+
if (!path.isAbsolute(entryFile)) {
|
|
72
|
+
throw new PiExtensionInstallError('invalid_entry', 'Pi Extension entry must be absolute');
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const resolved = fs.realpathSync(entryFile);
|
|
76
|
+
if (!fs.statSync(resolved).isFile())
|
|
77
|
+
throw new Error('not a file');
|
|
78
|
+
return resolved;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new PiExtensionInstallError('entry_missing', 'Bundled Pi Extension entry is unavailable');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function referencedEntryExists(entryUrl) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.statSync(fileURLToPath(entryUrl)).isFile();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function piExtensionStatus(home = homedir(), options) {
|
|
93
|
+
const installed = readManaged(piExtensionFile(home));
|
|
94
|
+
if (installed === null)
|
|
95
|
+
return 'absent';
|
|
96
|
+
if (installed === 'conflict')
|
|
97
|
+
return 'conflict';
|
|
98
|
+
if (!referencedEntryExists(installed.entryUrl))
|
|
99
|
+
return 'stale';
|
|
100
|
+
if (options?.entryFile !== undefined
|
|
101
|
+
&& installed.entryUrl !== expectedEntryUrl(options.entryFile))
|
|
102
|
+
return 'stale';
|
|
103
|
+
return 'installed';
|
|
104
|
+
}
|
|
105
|
+
function writeAtomic(file, content) {
|
|
106
|
+
const directory = path.dirname(file);
|
|
107
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
108
|
+
fs.chmodSync(directory, 0o700);
|
|
109
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
112
|
+
fs.renameSync(temporary, file);
|
|
113
|
+
fs.chmodSync(file, 0o600);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
try {
|
|
117
|
+
fs.unlinkSync(temporary);
|
|
118
|
+
}
|
|
119
|
+
catch { /* renamed or never created */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function installPiExtension(home = homedir(), { entryFile }) {
|
|
123
|
+
const resolvedEntry = validateEntry(entryFile);
|
|
124
|
+
const file = piExtensionFile(home);
|
|
125
|
+
const existing = readManaged(file);
|
|
126
|
+
if (existing === 'conflict') {
|
|
127
|
+
throw new PiExtensionInstallError('conflict', 'Pi Extension target exists and is not owned by Handmux');
|
|
128
|
+
}
|
|
129
|
+
const content = wrapper(resolvedEntry);
|
|
130
|
+
const changed = existing?.content !== content;
|
|
131
|
+
if (changed)
|
|
132
|
+
writeAtomic(file, content);
|
|
133
|
+
return { status: 'installed', changed, file, entryFile: resolvedEntry };
|
|
134
|
+
}
|
|
135
|
+
export function syncPiExtension(home = homedir(), options) {
|
|
136
|
+
const status = piExtensionStatus(home, options);
|
|
137
|
+
if (status === 'absent' || status === 'conflict')
|
|
138
|
+
return null;
|
|
139
|
+
return installPiExtension(home, options);
|
|
140
|
+
}
|
|
141
|
+
export function uninstallPiExtension(home = homedir()) {
|
|
142
|
+
const file = piExtensionFile(home);
|
|
143
|
+
const existing = readManaged(file);
|
|
144
|
+
if (existing === null)
|
|
145
|
+
return { changed: false, file };
|
|
146
|
+
if (existing === 'conflict') {
|
|
147
|
+
throw new PiExtensionInstallError('conflict', 'Pi Extension target exists and is not owned by Handmux');
|
|
148
|
+
}
|
|
149
|
+
fs.unlinkSync(file);
|
|
150
|
+
try {
|
|
151
|
+
fs.rmdirSync(path.dirname(file));
|
|
152
|
+
}
|
|
153
|
+
catch { /* preserve non-empty/user-owned directory */ }
|
|
154
|
+
return { changed: true, file };
|
|
155
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function probe(url, { fetchImpl = globalThis.fetch, timeoutMs = 6000, } = {}) {
|
|
2
|
+
if (!url)
|
|
3
|
+
return false;
|
|
4
|
+
const ac = new AbortController();
|
|
5
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
6
|
+
try {
|
|
7
|
+
await fetchImpl(url, { method: 'GET', redirect: 'manual', signal: ac.signal });
|
|
8
|
+
return true; // any HTTP response (even 401/404) means the chain is reachable
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
clearTimeout(t);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
// imports from here, so the prompt library stays swappable behind one seam and the wizard's pure logic
|
|
3
3
|
// (configFromAnswers / validators / …) never depends on it.
|
|
4
4
|
import { select, text, password, confirm, note, intro, outro, cancel, isCancel } from '@clack/prompts';
|
|
5
|
-
|
|
6
5
|
export { select, text, password, confirm, note, intro, outro, cancel };
|
|
7
|
-
|
|
8
6
|
// Thrown by ask() when the user cancels a prompt (Esc / Ctrl-C), so the wizard catches ONCE at the top
|
|
9
7
|
// instead of checking isCancel after every call.
|
|
10
8
|
export const CANCELLED = Symbol('setup-cancelled');
|
|
11
|
-
|
|
12
9
|
// Await a clack prompt, converting a cancel into a throw. Usage: `const v = await ask(select({…}))`.
|
|
13
10
|
export async function ask(promptResult) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
const v = await promptResult;
|
|
12
|
+
if (isCancel(v))
|
|
13
|
+
throw CANCELLED;
|
|
14
|
+
return v;
|
|
17
15
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — fire one
|
|
2
|
+
// notification to the phone through the already-running server (loopback + the server's own token).
|
|
3
|
+
// Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
|
|
4
|
+
// runner so it unit-tests without spawning or real fetch.
|
|
5
|
+
import { readState } from './state.js';
|
|
6
|
+
import { sanitizeNotificationUrl } from '../urlPolicy.js';
|
|
7
|
+
const collect = (acc, value) => acc.concat(String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean));
|
|
8
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
10
|
+
// argv is process.argv.slice(2), i.e. ['push', title, body, ...flags]. The shared parseArgs() drops bare
|
|
11
|
+
// words after the command, so title/body are taken positionally; --session/--device may repeat.
|
|
12
|
+
export function parsePushArgs(argv) {
|
|
13
|
+
const rest = argv.slice(1);
|
|
14
|
+
const positional = [];
|
|
15
|
+
let sessions = [];
|
|
16
|
+
let devices = [];
|
|
17
|
+
let tag;
|
|
18
|
+
let url;
|
|
19
|
+
for (let i = 0; i < rest.length; i++) {
|
|
20
|
+
const a = rest[i] ?? '';
|
|
21
|
+
if (a === '--session') {
|
|
22
|
+
sessions = collect(sessions, rest[++i]);
|
|
23
|
+
}
|
|
24
|
+
else if (a === '--device') {
|
|
25
|
+
devices = collect(devices, rest[++i]);
|
|
26
|
+
}
|
|
27
|
+
else if (a === '--tag') {
|
|
28
|
+
tag = rest[++i];
|
|
29
|
+
}
|
|
30
|
+
else if (a === '--url') {
|
|
31
|
+
url = rest[++i];
|
|
32
|
+
}
|
|
33
|
+
else if (!a.startsWith('--'))
|
|
34
|
+
positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
const [title, body] = positional;
|
|
37
|
+
if (!title || !body)
|
|
38
|
+
return { error: 'usage: handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]' };
|
|
39
|
+
if (sessions.length && devices.length)
|
|
40
|
+
return { error: 'use --session or --device, not both' };
|
|
41
|
+
const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
|
|
42
|
+
if (url != null && !safeUrl)
|
|
43
|
+
return { error: '--url must be an http(s) URL or a relative path' };
|
|
44
|
+
const out = { title, body };
|
|
45
|
+
if (sessions.length)
|
|
46
|
+
out.sessions = sessions;
|
|
47
|
+
if (devices.length)
|
|
48
|
+
out.devices = devices;
|
|
49
|
+
if (tag)
|
|
50
|
+
out.tag = tag;
|
|
51
|
+
if (safeUrl)
|
|
52
|
+
out.url = safeUrl;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
export async function runPush({ argv, home, fetchImpl = globalThis.fetch, log = console.log, err = console.error, }) {
|
|
56
|
+
const parsed = parsePushArgs(argv);
|
|
57
|
+
if (parsed.error) {
|
|
58
|
+
err(parsed.error);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
const st = readState(home);
|
|
62
|
+
if (!st || typeof st.localUrl !== 'string' || !st.localUrl
|
|
63
|
+
|| typeof st.token !== 'string' || !st.token) {
|
|
64
|
+
err('handmux is not running — run `handmux start` first.');
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify(parsed),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
err(`push failed: ${res.status ?? 'unknown'}`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
const raw = await res.json();
|
|
78
|
+
const out = isRecord(raw) ? raw : {};
|
|
79
|
+
if (out.configured === false) {
|
|
80
|
+
err('push is not configured (no VAPID keys) — run `handmux setup`.');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const sent = finiteNumber(out.sent);
|
|
84
|
+
const failed = finiteNumber(out.failed);
|
|
85
|
+
const gone = finiteNumber(out.gone);
|
|
86
|
+
const counts = `sent: ${sent}, failed: ${failed}, gone: ${gone}`;
|
|
87
|
+
if (sent === 0) {
|
|
88
|
+
err(`no notification delivered (${counts})`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (failed > 0) {
|
|
92
|
+
err(`push partially failed (${counts})`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
log(`pushed (${counts})`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
err(`push failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -10,25 +10,25 @@
|
|
|
10
10
|
//
|
|
11
11
|
// matrix[r][c] === true means a DARK module. renderCompactQr is pure (no I/O) so it's unit-tested; the CLI
|
|
12
12
|
// builds the matrix from qrcode-terminal's QR model and hands it here.
|
|
13
|
-
|
|
14
13
|
export function renderCompactQr(matrix, { quiet = 2 } = {}) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for (let
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
const n = matrix.length;
|
|
15
|
+
const side = n + quiet * 2;
|
|
16
|
+
// A module is LIGHT when it's outside the code (quiet zone, incl. a dangling final half-row) or the matrix
|
|
17
|
+
// cell is not dark.
|
|
18
|
+
const light = (r, c) => {
|
|
19
|
+
const mr = r - quiet, mc = c - quiet;
|
|
20
|
+
if (mr < 0 || mr >= n || mc < 0 || mc >= n)
|
|
21
|
+
return true;
|
|
22
|
+
return !(matrix[mr]?.[mc] ?? false);
|
|
23
|
+
};
|
|
24
|
+
let out = '';
|
|
25
|
+
for (let r = 0; r < side; r += 2) {
|
|
26
|
+
for (let c = 0; c < side; c++) {
|
|
27
|
+
const top = light(r, c);
|
|
28
|
+
const bottom = light(r + 1, c); // overflow row (odd side) is out-of-range → LIGHT, i.e. quiet border
|
|
29
|
+
out += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
30
|
+
}
|
|
31
|
+
out += '\n';
|
|
30
32
|
}
|
|
31
|
-
out
|
|
32
|
-
}
|
|
33
|
-
return out;
|
|
33
|
+
return out;
|
|
34
34
|
}
|