esque-bridge 0.6.6 → 0.6.8
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/index.js +142 -54
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -687,58 +687,98 @@ async function probeBuildError(port) {
|
|
|
687
687
|
return scanOutputForError(preview && preview.output);
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
// with its own generous timeout so it never collides with the 60s port-wait.
|
|
696
|
-
// Idempotent: skipped as soon as node_modules exists.
|
|
697
|
-
function ensureDeps() {
|
|
690
|
+
// Run one `sh -c <cmd>` inside the workdir, streaming a labelled copy of its
|
|
691
|
+
// output and keeping a rolling tail we can scan afterwards. Self-kills after
|
|
692
|
+
// timeoutMs so a wedged install can't hang the preview forever. Resolves
|
|
693
|
+
// { code, out } (code -1 on timeout/spawn error).
|
|
694
|
+
function runInWorkdir(cmd, label, timeoutMs) {
|
|
698
695
|
return new Promise((resolve) => {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
fs.existsSync(path.join(WORKDIR, 'package.json')) &&
|
|
703
|
-
!fs.existsSync(path.join(WORKDIR, 'node_modules'));
|
|
704
|
-
} catch {
|
|
705
|
-
needs = false;
|
|
706
|
-
}
|
|
707
|
-
if (!needs) return resolve(true);
|
|
708
|
-
|
|
709
|
-
// `--legacy-peer-deps`: AI-scaffolded Expo/RN projects routinely pin a
|
|
710
|
-
// React-18 app alongside test tooling that peer-wants React 19, which makes
|
|
711
|
-
// a strict `npm install` abort with ERESOLVE. The app runtime only needs
|
|
712
|
-
// the React it pins, so we tell npm to install the tree as written rather
|
|
713
|
-
// than fail the whole preview over a test-only peer mismatch.
|
|
714
|
-
console.log('[preview] installing dependencies (npm install) — first preview, this can take a few minutes…');
|
|
715
|
-
const proc = spawn('sh', ['-c', 'npm install --legacy-peer-deps --no-audit --no-fund'], {
|
|
716
|
-
cwd: WORKDIR,
|
|
717
|
-
env: process.env,
|
|
718
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
719
|
-
});
|
|
720
|
-
const onData = (d) => process.stdout.write(`[npm] ${d}`);
|
|
696
|
+
const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
697
|
+
let out = '';
|
|
698
|
+
const onData = (d) => { out = (out + d).slice(-20000); process.stdout.write(`[${label}] ${d}`); };
|
|
721
699
|
proc.stdout.on('data', onData);
|
|
722
700
|
proc.stderr.on('data', onData);
|
|
723
|
-
const timer = setTimeout(() => {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
resolve(false);
|
|
727
|
-
}, 360000);
|
|
728
|
-
proc.on('exit', (code) => {
|
|
729
|
-
clearTimeout(timer);
|
|
730
|
-
if (code === 0) console.log('[preview] dependencies installed');
|
|
731
|
-
else console.error(`[preview] npm install exited ${code} — preview may fail`);
|
|
732
|
-
resolve(code === 0);
|
|
733
|
-
});
|
|
734
|
-
proc.on('error', (e) => {
|
|
735
|
-
clearTimeout(timer);
|
|
736
|
-
console.error('[preview] npm install error:', e.message);
|
|
737
|
-
resolve(false);
|
|
738
|
-
});
|
|
701
|
+
const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* already gone */ } resolve({ code: -1, out }); }, timeoutMs);
|
|
702
|
+
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code, out }); });
|
|
703
|
+
proc.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: `${out}\n${e.message}` }); });
|
|
739
704
|
});
|
|
740
705
|
}
|
|
741
706
|
|
|
707
|
+
// The agent sometimes invents a dependency version that does not exist on the
|
|
708
|
+
// registry (e.g. `react-native-worklets@0.0.0`), and a single bad pin makes the
|
|
709
|
+
// whole `npm install` abort with ETARGET/E404. Pull the offending package name
|
|
710
|
+
// out of npm's error text and delete it from package.json (backing the original
|
|
711
|
+
// up once) so the next attempt can get past it. Returns the dropped name or null.
|
|
712
|
+
function dropUnresolvableDep(npmOutput) {
|
|
713
|
+
const m =
|
|
714
|
+
npmOutput.match(/No matching version found for (@?[\w.\/-]+)@/i) ||
|
|
715
|
+
npmOutput.match(/['"]?(@?[\w.\/-]+)@[^'"\s]+['"]? is not in this registry/i);
|
|
716
|
+
if (!m) return null;
|
|
717
|
+
const name = m[1];
|
|
718
|
+
const pkgPath = path.join(WORKDIR, 'package.json');
|
|
719
|
+
try {
|
|
720
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
721
|
+
let dropped = false;
|
|
722
|
+
for (const sec of ['dependencies', 'devDependencies']) {
|
|
723
|
+
if (pkg[sec] && Object.prototype.hasOwnProperty.call(pkg[sec], name)) { delete pkg[sec][name]; dropped = true; }
|
|
724
|
+
}
|
|
725
|
+
if (!dropped) return null;
|
|
726
|
+
const bak = `${pkgPath}.esque-bak`;
|
|
727
|
+
if (!fs.existsSync(bak)) fs.copyFileSync(pkgPath, bak);
|
|
728
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
729
|
+
return name;
|
|
730
|
+
} catch { return null; }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Make sure the project's dependencies are on disk before a preview tries to
|
|
734
|
+
// boot its dev server. The agent scaffolds package.json but deliberately leaves
|
|
735
|
+
// the slow install to Esque (the blueprint says so), so the first preview would
|
|
736
|
+
// otherwise die with "module 'expo' is not installed" / "next: not found".
|
|
737
|
+
// Idempotent — skipped as soon as node_modules exists.
|
|
738
|
+
async function ensureDeps() {
|
|
739
|
+
let isNode = false, hasModules = false, isExpo = false;
|
|
740
|
+
try {
|
|
741
|
+
const pkgPath = path.join(WORKDIR, 'package.json');
|
|
742
|
+
isNode = fs.existsSync(pkgPath);
|
|
743
|
+
hasModules = fs.existsSync(path.join(WORKDIR, 'node_modules'));
|
|
744
|
+
if (isNode) {
|
|
745
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
746
|
+
isExpo = !!(pkg.dependencies && pkg.dependencies.expo);
|
|
747
|
+
}
|
|
748
|
+
} catch { isNode = false; }
|
|
749
|
+
if (!isNode || hasModules) return true;
|
|
750
|
+
|
|
751
|
+
console.log('[preview] installing dependencies — first preview, this can take a few minutes…');
|
|
752
|
+
// `--legacy-peer-deps`: AI-scaffolded RN projects routinely pin a React-18 app
|
|
753
|
+
// beside test tooling that peer-wants React 19, which a strict install rejects.
|
|
754
|
+
const INSTALL = 'npm install --legacy-peer-deps --no-audit --no-fund';
|
|
755
|
+
|
|
756
|
+
let ok = false;
|
|
757
|
+
// Each failed pass may expose one invented version we can drop, then retry.
|
|
758
|
+
// Bounded so a genuinely unfixable project can't loop forever.
|
|
759
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
760
|
+
const { code, out } = await runInWorkdir(INSTALL, 'npm', 360000);
|
|
761
|
+
if (code === 0) { ok = true; break; }
|
|
762
|
+
const dropped = dropUnresolvableDep(out);
|
|
763
|
+
if (dropped) { console.log(`[preview] "${dropped}" has no installable version — removed it and retrying…`); continue; }
|
|
764
|
+
console.error(`[preview] npm install failed (exit ${code}) — preview may fail`);
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
if (!ok) return false;
|
|
768
|
+
console.log('[preview] dependencies installed');
|
|
769
|
+
|
|
770
|
+
// For Expo apps, realign every SDK-managed package to the version the SDK
|
|
771
|
+
// expects. The agent often mixes incompatible pins (e.g. Expo 56 with
|
|
772
|
+
// react-native 0.76), which compiles to a blank/broken web bundle; this makes
|
|
773
|
+
// the tree coherent. Non-fatal — keep the installed versions if it can't run.
|
|
774
|
+
if (isExpo) {
|
|
775
|
+
const { code } = await runInWorkdir('npx --yes expo install --fix', 'expo', 240000);
|
|
776
|
+
if (code === 0) console.log('[preview] aligned dependency versions to the Expo SDK');
|
|
777
|
+
else console.warn('[preview] could not run expo install --fix — continuing with installed versions');
|
|
778
|
+
}
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
|
|
742
782
|
async function startPreview(cmd, port) {
|
|
743
783
|
killPreview();
|
|
744
784
|
// Make sure node_modules exists before the dev server tries to boot.
|
|
@@ -1057,6 +1097,47 @@ function listenOnFreePort(app, startPort, maxTries = 25) {
|
|
|
1057
1097
|
});
|
|
1058
1098
|
}
|
|
1059
1099
|
|
|
1100
|
+
// Open the public pairing tunnel. Prefers cloudflared (reliable, no
|
|
1101
|
+
// interstitial), and falls back to localtunnel with a HARD timeout — otherwise
|
|
1102
|
+
// a slow/down localtunnel.me hangs the whole bridge before the QR ever prints
|
|
1103
|
+
// (exactly the "nothing happens after y" failure). Resolves a uniform handle
|
|
1104
|
+
// { url, kind, close(), onClose(cb) }, or null if both paths fail.
|
|
1105
|
+
function openPairTunnel(port) {
|
|
1106
|
+
return new Promise((resolve) => {
|
|
1107
|
+
let settled = false;
|
|
1108
|
+
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
1109
|
+
|
|
1110
|
+
const tryLocaltunnel = () => {
|
|
1111
|
+
const timer = setTimeout(() => done(null), 20000);
|
|
1112
|
+
localtunnel({ port, subdomain: LT_SUBDOMAIN })
|
|
1113
|
+
.then((t) => {
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
if (settled) { try { t.close(); } catch { /* already gone */ } return; }
|
|
1116
|
+
done({ url: t.url, kind: 'localtunnel', close: () => { try { t.close(); } catch { /* already gone */ } }, onClose: (cb) => t.on('close', cb) });
|
|
1117
|
+
})
|
|
1118
|
+
.catch(() => { clearTimeout(timer); done(null); });
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
if (!hasCloudflared()) { tryLocaltunnel(); return; }
|
|
1122
|
+
|
|
1123
|
+
const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1124
|
+
let gotUrl = false, fellBack = false;
|
|
1125
|
+
const scan = (d) => {
|
|
1126
|
+
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
1127
|
+
if (m && !gotUrl) {
|
|
1128
|
+
gotUrl = true;
|
|
1129
|
+
done({ url: m[0], kind: 'cloudflared', close: () => { try { proc.kill(); } catch { /* already gone */ } }, onClose: (cb) => proc.on('exit', cb) });
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
proc.stdout.on('data', scan);
|
|
1133
|
+
proc.stderr.on('data', scan);
|
|
1134
|
+
// cloudflared died before emitting a URL, or took too long → fall back once.
|
|
1135
|
+
const fallback = () => { if (!gotUrl && !fellBack) { fellBack = true; try { proc.kill(); } catch { /* already gone */ } tryLocaltunnel(); } };
|
|
1136
|
+
proc.on('exit', fallback);
|
|
1137
|
+
setTimeout(fallback, 25000);
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1060
1141
|
async function main() {
|
|
1061
1142
|
if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
|
|
1062
1143
|
console.error(`workdir does not exist: ${WORKDIR}`);
|
|
@@ -1096,15 +1177,17 @@ async function main() {
|
|
|
1096
1177
|
console.log(` ✓ Using port ${boundPort} instead (${PORT} was taken).`);
|
|
1097
1178
|
}
|
|
1098
1179
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
console.error('
|
|
1104
|
-
console.error('
|
|
1105
|
-
console.error(` cloudflared tunnel --url http://localhost:${boundPort}`);
|
|
1180
|
+
const tunnel = await openPairTunnel(boundPort);
|
|
1181
|
+
if (!tunnel) {
|
|
1182
|
+
console.error('\n ✗ Could not open a public tunnel.');
|
|
1183
|
+
console.error(' cloudflared and localtunnel.me both failed to respond.');
|
|
1184
|
+
console.error(' Check your internet connection and retry. For the reliable path:');
|
|
1185
|
+
console.error(' brew install cloudflared');
|
|
1106
1186
|
process.exit(1);
|
|
1107
1187
|
}
|
|
1188
|
+
if (tunnel.kind === 'localtunnel') {
|
|
1189
|
+
console.log(' (Using localtunnel — install cloudflared for a faster, steadier tunnel: brew install cloudflared)');
|
|
1190
|
+
}
|
|
1108
1191
|
|
|
1109
1192
|
const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
|
|
1110
1193
|
|
|
@@ -1129,7 +1212,9 @@ async function main() {
|
|
|
1129
1212
|
console.log(' Press Ctrl-C to stop.');
|
|
1130
1213
|
console.log('━'.repeat(68));
|
|
1131
1214
|
|
|
1215
|
+
let shuttingDown = false;
|
|
1132
1216
|
const shutdown = (signal) => {
|
|
1217
|
+
shuttingDown = true;
|
|
1133
1218
|
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
1134
1219
|
killPreview();
|
|
1135
1220
|
try {
|
|
@@ -1142,7 +1227,10 @@ async function main() {
|
|
|
1142
1227
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1143
1228
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1144
1229
|
|
|
1145
|
-
tunnel
|
|
1230
|
+
// Detect an unexpected tunnel drop (but stay quiet during our own shutdown,
|
|
1231
|
+
// since closing the tunnel also fires this).
|
|
1232
|
+
tunnel.onClose(() => {
|
|
1233
|
+
if (shuttingDown) return;
|
|
1146
1234
|
console.error('\nTunnel closed unexpectedly. Restart `esque-bridge`.');
|
|
1147
1235
|
process.exit(1);
|
|
1148
1236
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"esque-bridge": "index.js"
|