termbeam 1.23.1 → 1.23.2
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/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/tunnel/index.js +47 -4
- package/src/tunnel/install.js +104 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.23.2] - 2026-05-01
|
|
4
|
+
|
|
5
|
+
- fix(tunnel): auto-strip macOS quarantine xattr to prevent Gatekeeper stalls (@dorlugasigal)
|
|
6
|
+
- fix(tunnel): handle Windows PATHEXT lookup when binary name already has .exe (@dorlugasigal)
|
|
7
|
+
|
|
3
8
|
## [1.23.1] - 2026-05-01
|
|
4
9
|
|
|
5
10
|
- fix(touchbar): sort keys by col so reordered layouts render all keys (@dorlugasigal)
|
package/package.json
CHANGED
package/src/tunnel/index.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const dns = require('dns');
|
|
6
6
|
const EventEmitter = require('events');
|
|
7
7
|
const log = require('../utils/logger');
|
|
8
|
-
const { promptInstall } = require('./install');
|
|
8
|
+
const { promptInstall, stripQuarantine, resolveBinaryPath, hasQuarantine } = require('./install');
|
|
9
9
|
|
|
10
10
|
const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
|
|
11
11
|
const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
|
|
@@ -100,16 +100,53 @@ function isNetworkReachable() {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// One-time per-session flag so the "stripped quarantine" warning isn't
|
|
104
|
+
// spammed if the auth-poll loop trips it repeatedly during a brew upgrade.
|
|
105
|
+
let quarantineWarned = false;
|
|
106
|
+
|
|
103
107
|
function isLoggedIn() {
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
const tryShow = () =>
|
|
109
|
+
execFileSync(devtunnelCmd, ['user', 'show'], {
|
|
106
110
|
encoding: 'utf-8',
|
|
107
111
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
112
|
timeout: 10_000,
|
|
109
113
|
windowsHide: true,
|
|
110
114
|
});
|
|
115
|
+
try {
|
|
116
|
+
const out = tryShow();
|
|
111
117
|
return out && !out.toLowerCase().includes('not logged in');
|
|
112
|
-
} catch {
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// On macOS, brew upgrades silently re-tag the devtunnel binary with
|
|
120
|
+
// com.apple.quarantine, which causes Gatekeeper to block our spawn and
|
|
121
|
+
// makes this throw with no useful output. If we can prove the binary is
|
|
122
|
+
// actually quarantined, strip and retry once before reporting failure.
|
|
123
|
+
if (process.platform === 'darwin' && err && err.code !== 'ENOENT') {
|
|
124
|
+
const resolved = resolveBinaryPath(devtunnelCmd);
|
|
125
|
+
if (resolved && hasQuarantine(resolved)) {
|
|
126
|
+
const result = stripQuarantine(devtunnelCmd);
|
|
127
|
+
if (result === 'stripped') {
|
|
128
|
+
if (!quarantineWarned) {
|
|
129
|
+
log.warn(
|
|
130
|
+
'devtunnel was quarantined by macOS Gatekeeper (likely after a brew upgrade); ' +
|
|
131
|
+
'stripped com.apple.quarantine and retrying',
|
|
132
|
+
);
|
|
133
|
+
quarantineWarned = true;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const out = tryShow();
|
|
137
|
+
return out && !out.toLowerCase().includes('not logged in');
|
|
138
|
+
} catch {
|
|
139
|
+
// fall through and return false
|
|
140
|
+
}
|
|
141
|
+
} else if (result === 'failed' && !quarantineWarned) {
|
|
142
|
+
log.error(
|
|
143
|
+
'devtunnel is quarantined by macOS Gatekeeper but quarantine removal failed. ' +
|
|
144
|
+
`Run manually: xattr -dr com.apple.quarantine "${resolved}"`,
|
|
145
|
+
);
|
|
146
|
+
quarantineWarned = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
113
150
|
return false;
|
|
114
151
|
}
|
|
115
152
|
}
|
|
@@ -638,6 +675,12 @@ async function startTunnel(port, options = {}) {
|
|
|
638
675
|
}
|
|
639
676
|
devtunnelCmd = found;
|
|
640
677
|
|
|
678
|
+
// On macOS, brew --cask installs (and upgrades) tag the binary with
|
|
679
|
+
// com.apple.quarantine. The first time we spawn devtunnel from a
|
|
680
|
+
// non-interactive context Gatekeeper would block it and prompt the user.
|
|
681
|
+
// Strip the attribute on every startup so brew upgrades don't break us.
|
|
682
|
+
stripQuarantine(devtunnelCmd);
|
|
683
|
+
|
|
641
684
|
log.info('Starting devtunnel...');
|
|
642
685
|
try {
|
|
643
686
|
// Ensure user is logged in. Prefer Entra over GitHub — Entra tokens auto-refresh
|
package/src/tunnel/install.js
CHANGED
|
@@ -11,6 +11,98 @@ function getInstallDir() {
|
|
|
11
11
|
return INSTALL_DIR;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a binary name or path to an absolute path. Bare names (e.g. just
|
|
16
|
+
* "devtunnel") are looked up on `$PATH`. Returns the realpath (symlinks
|
|
17
|
+
* resolved) on success, or `null` if the binary couldn't be found.
|
|
18
|
+
*/
|
|
19
|
+
function resolveBinaryPath(binPathOrName) {
|
|
20
|
+
if (!binPathOrName) return null;
|
|
21
|
+
if (path.isAbsolute(binPathOrName)) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync(binPathOrName);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const PATH = process.env.PATH || '';
|
|
29
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
30
|
+
// On Windows, PATHEXT lists the extensions to append when the name has no
|
|
31
|
+
// extension. We always try the bare name first so callers passing
|
|
32
|
+
// 'devtunnel.exe' or 'node.exe' don't end up with 'node.exe.exe'.
|
|
33
|
+
const exts =
|
|
34
|
+
process.platform === 'win32'
|
|
35
|
+
? ['', ...(process.env.PATHEXT || '.EXE').split(';').map((e) => e.toLowerCase())]
|
|
36
|
+
: [''];
|
|
37
|
+
for (const dir of PATH.split(sep)) {
|
|
38
|
+
if (!dir) continue;
|
|
39
|
+
for (const ext of exts) {
|
|
40
|
+
const candidate = path.join(dir, binPathOrName + ext);
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.statSync(candidate);
|
|
43
|
+
if (stat.isFile()) {
|
|
44
|
+
try {
|
|
45
|
+
return fs.realpathSync(candidate);
|
|
46
|
+
} catch {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// not in this dir
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns true when the file at `absPath` carries the macOS
|
|
60
|
+
* `com.apple.quarantine` extended attribute. Always false on non-darwin.
|
|
61
|
+
*/
|
|
62
|
+
function hasQuarantine(absPath) {
|
|
63
|
+
if (process.platform !== 'darwin' || !absPath) return false;
|
|
64
|
+
try {
|
|
65
|
+
execFileSync('xattr', ['-p', 'com.apple.quarantine', absPath], {
|
|
66
|
+
stdio: 'pipe',
|
|
67
|
+
timeout: 3000,
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strip the macOS `com.apple.quarantine` extended attribute from a binary.
|
|
77
|
+
* Brew casks tag downloaded binaries with this attribute, which causes
|
|
78
|
+
* Gatekeeper to block execution and pop a system dialog ("are you sure you
|
|
79
|
+
* want to open…") the first time the binary runs in a non-interactive
|
|
80
|
+
* context (e.g. spawned from a service). On the next brew upgrade the new
|
|
81
|
+
* binary is re-tagged, so this needs to run after every install and as a
|
|
82
|
+
* best-effort self-heal step at runtime.
|
|
83
|
+
*
|
|
84
|
+
* Accepts an absolute path OR a bare command name (which is resolved via
|
|
85
|
+
* `$PATH`). Returns one of:
|
|
86
|
+
* - 'noop' — non-darwin, unresolvable, or no quarantine attribute
|
|
87
|
+
* - 'stripped' — quarantine was present and successfully removed
|
|
88
|
+
* - 'failed' — quarantine was present and removal failed (e.g. EPERM)
|
|
89
|
+
*/
|
|
90
|
+
function stripQuarantine(binPathOrName) {
|
|
91
|
+
if (process.platform !== 'darwin' || !binPathOrName) return 'noop';
|
|
92
|
+
const resolved = resolveBinaryPath(binPathOrName);
|
|
93
|
+
if (!resolved) return 'noop';
|
|
94
|
+
if (!hasQuarantine(resolved)) return 'noop';
|
|
95
|
+
try {
|
|
96
|
+
execFileSync('xattr', ['-d', 'com.apple.quarantine', resolved], {
|
|
97
|
+
stdio: 'pipe',
|
|
98
|
+
timeout: 5000,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
// Removal refused (e.g. EPERM). Verify outcome below.
|
|
102
|
+
}
|
|
103
|
+
return hasQuarantine(resolved) ? 'failed' : 'stripped';
|
|
104
|
+
}
|
|
105
|
+
|
|
14
106
|
function getBinaryName() {
|
|
15
107
|
return process.platform === 'win32' ? 'devtunnel.exe' : 'devtunnel';
|
|
16
108
|
}
|
|
@@ -83,6 +175,10 @@ async function installDevtunnel() {
|
|
|
83
175
|
// Find the installed binary
|
|
84
176
|
const found = findInstalledBinary();
|
|
85
177
|
if (found) {
|
|
178
|
+
// On macOS, brew --cask tags the binary with com.apple.quarantine which
|
|
179
|
+
// causes Gatekeeper to block execution from non-interactive contexts.
|
|
180
|
+
// Strip it now so the first spawn doesn't trigger a system dialog.
|
|
181
|
+
stripQuarantine(found);
|
|
86
182
|
log.info(`${green('✔')} DevTunnel CLI installed and verified successfully.`);
|
|
87
183
|
return found;
|
|
88
184
|
}
|
|
@@ -139,4 +235,11 @@ function findInstalledBinary() {
|
|
|
139
235
|
return null;
|
|
140
236
|
}
|
|
141
237
|
|
|
142
|
-
module.exports = {
|
|
238
|
+
module.exports = {
|
|
239
|
+
installDevtunnel,
|
|
240
|
+
promptInstall,
|
|
241
|
+
getInstallDir,
|
|
242
|
+
stripQuarantine,
|
|
243
|
+
resolveBinaryPath,
|
|
244
|
+
hasQuarantine,
|
|
245
|
+
};
|