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.
@@ -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, getLocale } from './i18n/index.js';
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
- // ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
21
- export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port }) {
22
- return [
23
- `tunnel: ${tunnelName}`,
24
- `credentials-file: ${credentialsFile}`,
25
- 'ingress:',
26
- ` - hostname: ${hostname}`,
27
- ` service: http://localhost:${port}`,
28
- ' - service: http_status:404',
29
- '',
30
- ].join('\n');
31
- }
32
-
33
- // Extract the tunnel UUID + credentials path from `cloudflared tunnel create <name>` stdout.
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
- fs.writeFileSync(statePath(home), JSON.stringify(state, null, 2));
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) {
@@ -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));
@@ -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
- const EXT = {
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;