handmux 0.14.0 → 0.16.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/bin/handmux.js +24 -3
- package/package.json +4 -4
- package/public/assets/index-Cf-WEkt5.js +330 -0
- package/public/assets/index-D5UQBvph.css +32 -0
- package/public/index.html +2 -2
- package/src/claudeEvents.js +70 -3
- package/src/cli/claudeHooks.js +3 -17
- package/src/cli/codexHooks.js +5 -17
- package/src/cli/hookScaffold.js +35 -0
- package/src/cli/i18n/en.js +2 -0
- package/src/cli/i18n/zh.js +2 -0
- package/src/cli/setupModel.js +145 -0
- package/src/cli/setupWizard.js +15 -140
- package/src/cli/state.js +7 -1
- package/src/cli/statusLine.js +1 -5
- package/src/cli/updateCheck.js +7 -1
- package/src/docPath.js +4 -2
- package/src/httpApi.js +26 -710
- package/src/jsonStore.js +24 -0
- package/src/previews.js +18 -22
- package/src/push.js +2 -6
- package/src/routes/files.js +172 -0
- package/src/routes/git.js +57 -0
- package/src/routes/previews.js +40 -0
- package/src/routes/push.js +82 -0
- package/src/routes/sessions.js +151 -0
- package/src/routes/system.js +130 -0
- package/src/routes/terminal.js +167 -0
- package/src/tmux/commands.js +33 -5
- package/public/assets/index-BIOe84T4.css +0 -32
- package/public/assets/index-CL5hFo5W.js +0 -162
package/src/cli/setupWizard.js
CHANGED
|
@@ -14,148 +14,23 @@ import { defaultGen as genToken } from './options.js';
|
|
|
14
14
|
import { resolveCloudflared } from './cloudflared.js';
|
|
15
15
|
import { resolveTunlite, checkSshAuth } from './tunlite.js';
|
|
16
16
|
import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
|
|
17
|
-
import { t, setLocale
|
|
17
|
+
import { t, setLocale } from './i18n/index.js';
|
|
18
18
|
import { intro, outro, note, cancel, select, text, password, confirm, ask, CANCELLED } from './prompt.js';
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export function parseTunnelCreate(out) {
|
|
35
|
-
const s = String(out || '');
|
|
36
|
-
const id = (s.match(/Created tunnel \S+ with id ([0-9a-fA-F-]+)/) || [])[1] || null;
|
|
37
|
-
const credentialsFile = (s.match(/credentials written to (\S+\.json)/i) || [])[1]?.replace(/\.$/, '') || null;
|
|
38
|
-
return { id, credentialsFile };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Find an existing named tunnel's UUID in `cloudflared tunnel list --output json`. A named tunnel is a
|
|
42
|
-
// PERSISTENT object in the Cloudflare account, so a second `setup` would hit `cloudflared tunnel create`'s
|
|
43
|
-
// "tunnel with name X already exists" error — which used to force a rename and pile up junk tunnels in the
|
|
44
|
-
// account. Looking it up first lets provisioning REUSE it idempotently. Tolerates non-JSON / errors → null.
|
|
45
|
-
export function findTunnelId(listJsonOut, name) {
|
|
46
|
-
let arr;
|
|
47
|
-
try { arr = JSON.parse(String(listJsonOut || '')); } catch { return null; }
|
|
48
|
-
if (!Array.isArray(arr)) return null;
|
|
49
|
-
const hit = arr.find((tn) => tn && tn.name === name);
|
|
50
|
-
return hit?.id || null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
|
|
54
|
-
// before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
|
|
55
|
-
// old value instead of leaving a stale field behind. Anything NOT here (staticDir, previewDomain…) is
|
|
56
|
-
// preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
|
|
57
|
-
// round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
|
|
58
|
-
const WIZARD_KEYS = [
|
|
59
|
-
'lang', 'name', 'port', 'tunnel', 'token',
|
|
60
|
-
'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
|
|
61
|
-
'authtoken', 'cpolarRegion',
|
|
62
|
-
'vapid', 'xfyun',
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
// The tunnel-specific keys, cleared whenever the tunnel changes so a switch never carries a stale field.
|
|
66
|
-
const TUNNEL_KEYS = ['sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl', 'authtoken', 'cpolarRegion'];
|
|
67
|
-
|
|
68
|
-
// Wizard answers → the config fragment the user actually set (omit empty optional fields).
|
|
69
|
-
export function configFromAnswers(a) {
|
|
70
|
-
const cfg = { tunnel: a.tunnel, port: a.port };
|
|
71
|
-
if (a.lang) cfg.lang = a.lang;
|
|
72
|
-
if (a.name) cfg.name = a.name;
|
|
73
|
-
if (a.token) cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
|
|
74
|
-
if (a.tunnel === 'ssh') {
|
|
75
|
-
cfg.sshHost = a.sshHost;
|
|
76
|
-
cfg.remotePort = a.remotePort;
|
|
77
|
-
if (a.publicUrl) cfg.publicUrl = a.publicUrl;
|
|
78
|
-
if (a.sshJump) cfg.sshJump = a.sshJump;
|
|
79
|
-
}
|
|
80
|
-
if (a.tunnel === 'cloudflare-named') {
|
|
81
|
-
cfg.cfHostname = a.cfHostname;
|
|
82
|
-
cfg.cfTunnelName = a.cfTunnelName;
|
|
83
|
-
}
|
|
84
|
-
if (a.tunnel === 'natapp' || a.tunnel === 'cpolar') {
|
|
85
|
-
if (a.authtoken) cfg.authtoken = a.authtoken;
|
|
86
|
-
if (a.publicUrl) cfg.publicUrl = a.publicUrl; // fixed/reserved domain (blank = temporary random)
|
|
87
|
-
if (a.cpolarRegion) cfg.cpolarRegion = a.cpolarRegion;
|
|
88
|
-
}
|
|
89
|
-
if (a.vapid) cfg.vapid = a.vapid;
|
|
90
|
-
if (a.xfyun) cfg.xfyun = a.xfyun;
|
|
91
|
-
return cfg;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Fold this run's answers into an existing config: preserve every non-wizard field, replace the wizard's
|
|
95
|
-
// own fields wholesale. This is why re-running `setup` to switch tunnels (or edit the name) never drops
|
|
96
|
-
// your token / push keys / static dir, yet also never leaves the previous tunnel's stale keys around.
|
|
97
|
-
export function mergeConfig(existing = {}, answers) {
|
|
98
|
-
const out = { ...existing };
|
|
99
|
-
for (const k of WIZARD_KEYS) delete out[k];
|
|
100
|
-
return { ...out, ...configFromAnswers(answers) };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Seed the working answers from an existing config so the hub shows current values and each edit starts
|
|
104
|
-
// from what's already there. A brand-new config yields safe defaults (none/LAN, port 19999).
|
|
105
|
-
export function answersFromConfig(cfg = {}) {
|
|
106
|
-
const a = {
|
|
107
|
-
lang: cfg.lang || getLocale(),
|
|
108
|
-
name: cfg.name || '',
|
|
109
|
-
token: cfg.token || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
|
|
110
|
-
tunnel: cfg.tunnel || 'none',
|
|
111
|
-
port: Number(cfg.port) || 19999,
|
|
112
|
-
};
|
|
113
|
-
for (const k of [...TUNNEL_KEYS, 'vapid', 'xfyun']) if (cfg[k] != null) a[k] = cfg[k];
|
|
114
|
-
return a;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// The dim one-line summary shown next to the Connection row (and reused nowhere else). Pure.
|
|
118
|
-
export function summarizeConnection(a) {
|
|
119
|
-
const bare = (u) => String(u || '').replace(/^https?:\/\//, '');
|
|
120
|
-
switch (a.tunnel) {
|
|
121
|
-
case 'cloudflare': return `cloudflare · ${t('setup.sumTemp')}`;
|
|
122
|
-
case 'cloudflare-named': return `cloudflare-named · ${a.cfHostname || '?'}`;
|
|
123
|
-
case 'ssh': return `ssh · ${a.sshHost || '?'}`;
|
|
124
|
-
case 'natapp':
|
|
125
|
-
case 'cpolar': return `${a.tunnel} · ${a.publicUrl ? `${t('setup.sumFixed')} ${bare(a.publicUrl)}` : t('setup.sumTemp')}`;
|
|
126
|
-
default: return `${t('setup.tunnelNone')} · ${t('setup.sumNoRelay')}`;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ---- validators (clack: return a string to reject + show inline, undefined to accept). Pure. ----
|
|
131
|
-
export function validatePort(v) {
|
|
132
|
-
const n = Number(v);
|
|
133
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535) return t('setup.valPort');
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
export const validateNonEmpty = (label) => (v) => (String(v || '').trim() ? undefined : t('setup.valRequired', { label }));
|
|
137
|
-
export function validateHost(v) {
|
|
138
|
-
const s = String(v || '').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
139
|
-
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valHost');
|
|
140
|
-
}
|
|
141
|
-
// VAPID subject: Apple (APNs) rejects a fake/.local domain with BadJwtToken, so require a real-looking
|
|
142
|
-
// mailto:you@host.tld or an https:// URL and reject the known-bad .local. Keeps push from silently
|
|
143
|
-
// failing on iOS. (Can't fully validate "real" client-side — this just catches the obvious footguns.)
|
|
144
|
-
export function validateContact(v) {
|
|
145
|
-
const s = String(v || '').trim();
|
|
146
|
-
const wellFormed = /^mailto:[^@\s]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s)
|
|
147
|
-
|| /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(s);
|
|
148
|
-
if (!wellFormed || /\.local(?:[:/]|$)/i.test(s)) return t('setup.valContact');
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
// Access token: it rides in the phone's URL as ?token=…, so require something and reject whitespace (a space
|
|
152
|
-
// would break the link). Length/charset are otherwise up to the user — a pinned token is used verbatim.
|
|
153
|
-
export function validateToken(v) {
|
|
154
|
-
const s = String(v || '').trim();
|
|
155
|
-
if (!s) return t('setup.valToken');
|
|
156
|
-
if (/\s/.test(s)) return t('setup.valTokenSpace');
|
|
157
|
-
return undefined;
|
|
158
|
-
}
|
|
20
|
+
// The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
|
|
21
|
+
// the interactive shell below; re-exported so their historical import path (this module) stays valid for
|
|
22
|
+
// callers and tests.
|
|
23
|
+
import {
|
|
24
|
+
cfConfigYaml, parseTunnelCreate, findTunnelId,
|
|
25
|
+
mergeConfig, answersFromConfig, summarizeConnection,
|
|
26
|
+
validatePort, validateHost, validateNonEmpty, validateContact, validateToken,
|
|
27
|
+
TUNNEL_KEYS,
|
|
28
|
+
} from './setupModel.js';
|
|
29
|
+
export {
|
|
30
|
+
cfConfigYaml, parseTunnelCreate, findTunnelId,
|
|
31
|
+
configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection,
|
|
32
|
+
validatePort, validateHost, validateNonEmpty, validateContact, validateToken,
|
|
33
|
+
} from './setupModel.js';
|
|
159
34
|
|
|
160
35
|
function readExisting(file) {
|
|
161
36
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
package/src/cli/state.js
CHANGED
|
@@ -25,9 +25,15 @@ export function readState(home) {
|
|
|
25
25
|
try { return JSON.parse(fs.readFileSync(statePath(home), 'utf8')); } catch { return null; }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Atomic write (tmp + rename): the supervisor persist()s frequently while the CLI concurrently readState()s
|
|
29
|
+
// (waitAndPrint/status), so a plain writeFileSync could be caught mid-write and JSON.parse-fail — silently
|
|
30
|
+
// degrading to "not running". rename is atomic on the same filesystem, so a reader sees old or new, never torn.
|
|
28
31
|
export function writeState(state, home) {
|
|
29
32
|
fs.mkdirSync(pocketHome(home), { recursive: true });
|
|
30
|
-
|
|
33
|
+
const file = statePath(home);
|
|
34
|
+
const tmp = `${file}.tmp`;
|
|
35
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export function clearState(home) {
|
package/src/cli/statusLine.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
+
import { writeJsonAtomic } from './hookScaffold.js';
|
|
12
13
|
|
|
13
14
|
const STATUS_MARK = 'handmux-statusline.cjs'; // identifies our statusLine command among the user's own
|
|
14
15
|
const SCRIPT = 'handmux-statusline.cjs';
|
|
@@ -19,11 +20,6 @@ function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'set
|
|
|
19
20
|
function readSettings(home) {
|
|
20
21
|
try { return JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { return {}; }
|
|
21
22
|
}
|
|
22
|
-
function writeJsonAtomic(file, obj) {
|
|
23
|
-
const tmp = `${file}.tmp`;
|
|
24
|
-
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
25
|
-
fs.renameSync(tmp, file);
|
|
26
|
-
}
|
|
27
23
|
|
|
28
24
|
function isOurs(sl) {
|
|
29
25
|
return !!(sl && typeof sl.command === 'string' && sl.command.includes(STATUS_MARK));
|
package/src/cli/updateCheck.js
CHANGED
|
@@ -16,6 +16,12 @@ import { t } from './i18n/index.js';
|
|
|
16
16
|
export const PKG_NAME = 'handmux';
|
|
17
17
|
export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
|
|
18
18
|
|
|
19
|
+
// A Homebrew-installed handmux lives under the Cellar (…/Cellar/handmux/<ver>/…, symlinked from the brew
|
|
20
|
+
// prefix's bin), or under a linuxbrew prefix. For such installs `handmux update` must NOT `npm i -g` over
|
|
21
|
+
// itself — that plants a second, conflicting copy that brew can't see or upgrade — and the "how to upgrade"
|
|
22
|
+
// hint must point at `brew upgrade` instead. Detect the source from the (real, symlink-resolved) entry path.
|
|
23
|
+
export function isBrewInstall(selfPath = '') { return /\/(Cellar|homebrew)\//.test(selfPath); }
|
|
24
|
+
|
|
19
25
|
export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
|
|
20
26
|
|
|
21
27
|
export function readCache(home) {
|
|
@@ -114,7 +120,7 @@ export function notifyUpdate(home, { version, selfPath, now = Date.now(), log =
|
|
|
114
120
|
if (cache && cache.latest && isNewer(cache.latest, version)) {
|
|
115
121
|
log('');
|
|
116
122
|
log(t('update.available', { current: version, latest: cache.latest }));
|
|
117
|
-
log(t('update.how'));
|
|
123
|
+
log(t(isBrewInstall(selfPath) ? 'update.howBrew' : 'update.how'));
|
|
118
124
|
shown = true;
|
|
119
125
|
}
|
|
120
126
|
if (selfPath && shouldRefresh(cache, now)) {
|
package/src/docPath.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
// Renderable doc types by extension. Exported so the web mirror (docPath.js DOC_LINK_EXTS) can be
|
|
2
|
+
// parity-checked against it — the client's tappable-path detector must open exactly what we can render.
|
|
3
|
+
export const EXT = {
|
|
2
4
|
'.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html',
|
|
3
5
|
// Plain-text files: rendered verbatim in a <pre> (no markdown parsing).
|
|
4
6
|
'.txt': 'text', '.log': 'text', '.sh': 'text',
|
|
@@ -12,7 +14,7 @@ export function docTypeFor(name) {
|
|
|
12
14
|
|
|
13
15
|
// Image extensions the in-app viewer can show inline via <img> (GIF animates natively). SVG is safe
|
|
14
16
|
// here because <img>-loaded SVG never runs its scripts. Returns 'image' or null. Case-insensitive.
|
|
15
|
-
const IMG_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']);
|
|
17
|
+
export const IMG_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']);
|
|
16
18
|
export function imageTypeFor(name) {
|
|
17
19
|
const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
|
|
18
20
|
return m && IMG_EXT.has(m[1].toLowerCase()) ? 'image' : null;
|