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.
Files changed (2) hide show
  1. package/index.js +142 -54
  2. 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
- // Runnable previews (`expo start`, `next dev`, `vite`, …) need the project's
691
- // dependencies on disk. The agent scaffolds package.json but deliberately does
692
- // NOT run a slow `npm install` itself (the blueprint hands that to Esque) — so
693
- // the very first preview would otherwise die with "module 'expo' is not
694
- // installed" / "next: command not found". This installs them once, up front,
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
- let needs;
700
- try {
701
- needs =
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
- console.error('[preview] npm install exceeded 6 min moving on (preview may fail)');
725
- try { proc.kill('SIGKILL'); } catch { /* already gone */ }
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
- let tunnel;
1100
- try {
1101
- tunnel = await localtunnel({ port: boundPort, subdomain: LT_SUBDOMAIN });
1102
- } catch (err) {
1103
- console.error('Failed to open localtunnel:', err.message);
1104
- console.error('If localtunnel.me is blocked, try cloudflared:');
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.on('close', () => {
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.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"