pi-bridge 0.3.3 → 0.3.5

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/pii.js CHANGED
@@ -1,77 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { spawnSync, execFileSync } = require('child_process');
4
+ const { spawnSync } = require('child_process');
5
5
  const path = require('path');
6
- const fs = require('fs');
7
- const os = require('os');
8
6
 
9
7
  const pkg = require('../package.json');
10
8
  const preload = path.join(__dirname, '..', 'src', 'local', 'preload.js');
9
+ const { findPi } = require('../src/local/find-pi.js');
11
10
 
12
11
  if (process.argv.includes('--version') || process.argv.includes('-v') || process.argv.includes('--v')) {
13
12
  process.stdout.write('pi-bridge/' + pkg.version + '\n');
14
13
  process.exit(0);
15
14
  }
16
15
 
17
- // List every `pi` on the PATH that pii inherited from its launching shell.
18
- // We resolve against that PATH (not a fresh `-NoProfile` shell) so that bundled
19
- // installs added to PATH via the user's profile are actually visible.
20
- function locateOnPath(cmd) {
21
- const finder = os.platform() === 'win32' ? 'where.exe' : 'which';
22
- try {
23
- const out = execFileSync(finder, [cmd], { encoding: 'utf8' });
24
- return out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
25
- } catch (_) {
26
- return [];
27
- }
28
- }
29
-
30
- function findPi() {
31
- // 1. Standard npm global: pii and pi share the same node_modules prefix.
32
- // Prefer the current @earendil-works scope; keep @mariozechner as a legacy
33
- // fallback for machines that installed pi before it moved off that scope.
34
- const sharedCandidates = [
35
- path.resolve(__dirname, '..', '..', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js'),
36
- path.resolve(__dirname, '..', '..', '@mariozechner', 'pi-coding-agent', 'dist', 'cli.js'),
37
- ];
38
- for (const cli of sharedCandidates) {
39
- if (fs.existsSync(cli)) return { cli, nodeExe: process.execPath };
40
- }
41
-
42
- // 2. Resolve `pi` from PATH and follow it to its real JS entrypoint. The
43
- // pi.dev installer (curl https://pi.dev/install.sh | sh) drops a bundled
44
- // Node plus a bare `bin/pi` symlink that points straight at the package's
45
- // cli.js — often outside any npm prefix — so we realpath the symlink rather
46
- // than guessing filenames. Also covers npm shims and dev clones (which
47
- // expose pi as a symlink to cli.js too), and a bundled node next to pi is
48
- // preferred for version-matched execution.
49
- const exe = os.platform() === 'win32' ? '.exe' : '';
50
- for (const cmd of locateOnPath('pi')) {
51
- let real;
52
- try { real = fs.realpathSync(cmd); } catch (_) { real = cmd; }
53
-
54
- let cli = null;
55
- if (/\.[cm]?js$/i.test(real) && fs.existsSync(real)) {
56
- cli = real; // symlink/shim -> cli.js
57
- } else if (fs.existsSync(path.join(path.dirname(cmd), 'pi.js'))) {
58
- cli = path.join(path.dirname(cmd), 'pi.js'); // bundled wrapper + sibling
59
- } else if (!/\.(cmd|ps1|bat|exe)$/i.test(real)) {
60
- cli = real; // shebang JS with no extension
61
- }
62
- if (!cli) continue;
63
-
64
- const localNode = path.join(path.dirname(cmd), 'node' + exe);
65
- const nodeExe = fs.existsSync(localNode) ? localNode : process.execPath;
66
- return { cli, nodeExe };
67
- }
68
-
69
- return null;
70
- }
71
-
72
16
  let pi;
73
17
  try {
74
- pi = findPi();
18
+ pi = findPi({ bridgeBinDir: __dirname });
75
19
  } catch (_) {}
76
20
 
77
21
  if (!pi) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bridge",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Transparent filesystem bridge that makes pi run natively on a remote machine over SSH",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // Locates the pi CLI entrypoint (a JS file) and the node binary to run it with.
4
+ // Used by bin/pii.js; kept separate so it can be unit-tested.
5
+
6
+ const { execFileSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+
10
+ // Prefer the current @earendil-works scope; keep @mariozechner as a legacy
11
+ // fallback for machines that installed pi before it moved off that scope.
12
+ const PI_PACKAGES = [
13
+ path.join('@earendil-works', 'pi-coding-agent'),
14
+ path.join('@mariozechner', 'pi-coding-agent'),
15
+ ];
16
+
17
+ // List every `pi` on the PATH that pii inherited from its launching shell.
18
+ // We resolve against that PATH (not a fresh `-NoProfile` shell) so that bundled
19
+ // installs added to PATH via the user's profile are actually visible.
20
+ function locateOnPath(cmd, platform = process.platform) {
21
+ const finder = platform === 'win32' ? 'where.exe' : 'which';
22
+ try {
23
+ const out = execFileSync(finder, [cmd], { encoding: 'utf8' });
24
+ return out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
25
+ } catch (_) {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ // Resolve a pi package's CLI from its package.json `bin` field, so we follow
31
+ // whatever entrypoint the installed version declares (dist/cli.js in older
32
+ // releases, dist/bundle/cli.js since 1.5).
33
+ function cliFromPackage(pkgDir) {
34
+ let rel = null;
35
+ try {
36
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
37
+ rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin && pkg.bin.pi;
38
+ } catch (_) {
39
+ return null;
40
+ }
41
+ const candidates = [rel, 'dist/bundle/cli.js', 'dist/cli.js'].filter(Boolean);
42
+ for (const c of candidates) {
43
+ const cli = path.resolve(pkgDir, c);
44
+ if (fs.existsSync(cli)) return cli;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function cliFromNodeModules(nodeModulesDir) {
50
+ for (const name of PI_PACKAGES) {
51
+ const cli = cliFromPackage(path.join(nodeModulesDir, name));
52
+ if (cli) return cli;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // True if the file is something node can execute: either it has a node
58
+ // shebang or no shebang at all. Rejects npm's extensionless `sh` shims
59
+ // (`#!/bin/sh` + `basedir=$(dirname ...)`), which node would choke on.
60
+ function looksLikeNodeScript(file) {
61
+ let head;
62
+ try {
63
+ const fd = fs.openSync(file, 'r');
64
+ try {
65
+ const buf = Buffer.alloc(256);
66
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
67
+ head = buf.toString('utf8', 0, n);
68
+ } finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ } catch (_) {
72
+ return false;
73
+ }
74
+ if (!head.startsWith('#!')) return true;
75
+ return /\bnode\b/.test(head.split(/\r?\n/, 1)[0]);
76
+ }
77
+
78
+ function findPi({
79
+ bridgeBinDir,
80
+ locate = locateOnPath,
81
+ platform = process.platform,
82
+ execPath = process.execPath,
83
+ } = {}) {
84
+ // 1. Standard npm global: pii and pi share the same node_modules dir
85
+ // (<node_modules>/pi-bridge/bin -> <node_modules>).
86
+ if (bridgeBinDir) {
87
+ const cli = cliFromNodeModules(path.resolve(bridgeBinDir, '..', '..'));
88
+ if (cli) return { cli, nodeExe: execPath };
89
+ }
90
+
91
+ // 2. Resolve `pi` from PATH. Covers npm globals when pi-bridge itself is
92
+ // linked from a local checkout, the pi.dev installer (bundled Node plus a
93
+ // bare `bin/pi` symlink straight to cli.js, often outside any npm prefix)
94
+ // and dev clones.
95
+ const exe = platform === 'win32' ? '.exe' : '';
96
+ for (const cmd of locate('pi', platform)) {
97
+ const dir = path.dirname(cmd);
98
+ const localNode = path.join(dir, 'node' + exe);
99
+ const nodeExe = fs.existsSync(localNode) ? localNode : execPath;
100
+
101
+ // 2a. npm prefix layouts next to the launcher:
102
+ // Windows: <prefix>/pi(.cmd) + <prefix>/node_modules/...
103
+ // POSIX: <prefix>/bin/pi + <prefix>/lib/node_modules/...
104
+ const npmCli =
105
+ cliFromNodeModules(path.join(dir, 'node_modules')) ||
106
+ cliFromNodeModules(path.join(dir, '..', 'lib', 'node_modules'));
107
+ if (npmCli) return { cli: npmCli, nodeExe };
108
+
109
+ // 2b. Follow the launcher to its real JS entrypoint.
110
+ let real;
111
+ try { real = fs.realpathSync(cmd); } catch (_) { real = cmd; }
112
+
113
+ let cli = null;
114
+ if (/\.[cm]?js$/i.test(real) && fs.existsSync(real)) {
115
+ cli = real; // symlink/shim -> cli.js
116
+ } else if (fs.existsSync(path.join(dir, 'pi.js'))) {
117
+ cli = path.join(dir, 'pi.js'); // bundled wrapper + sibling
118
+ } else if (!/\.(cmd|ps1|bat|exe)$/i.test(real) && looksLikeNodeScript(real)) {
119
+ cli = real; // shebang JS with no extension
120
+ }
121
+ if (cli) return { cli, nodeExe };
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ module.exports = { findPi, cliFromPackage, looksLikeNodeScript, locateOnPath };
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const fsp = require('fs/promises');
5
5
  const http = require('http');
6
6
  const path = require('path');
7
+ const { fileURLToPath } = require('url');
7
8
  const { isFakePath, toRemotePath } = require('./path-mapper');
8
9
  const { httpGet, httpPost } = require('./http-client');
9
10
 
@@ -127,9 +128,28 @@ function patchFs(fakeRoot, port, token) {
127
128
  cp: fs.promises.cp ? fs.promises.cp.bind(fs.promises) : undefined,
128
129
  };
129
130
 
130
- // Resolve any path (relative or absolute) to absolute before checking fake root
131
+ // Resolve any path (relative or absolute) to absolute before checking fake root.
132
+ // file: URLs are accepted too: jiti's bundled ESM resolver probes package
133
+ // mains with fs.statSync(new URL(...)), which must reach the remote.
134
+ // Buffer paths deliberately pass through: Node 20's JS rmSync walks children
135
+ // as Buffers via the public fs.*, and routing those would make removeFakeDir
136
+ // list and delete files on the remote.
131
137
  function abs(p) {
132
- return typeof p === 'string' ? path.resolve(p) : null;
138
+ if (typeof p === 'string') return path.resolve(p);
139
+ if (p instanceof URL) return p.protocol === 'file:' ? path.resolve(fileURLToPath(p)) : null;
140
+ return null;
141
+ }
142
+
143
+ // Shared by statSync/lstatSync. Honours { throwIfNoEntry: false }, which the
144
+ // ESM resolver relies on to probe candidate files without try/catch.
145
+ function statRemoteSync(s, opts) {
146
+ try {
147
+ return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
148
+ } catch (e) {
149
+ const missing = e && e.message && (e.message.includes('ENOENT') || e.message.includes('403'));
150
+ if (missing && opts && opts.throwIfNoEntry === false) return undefined;
151
+ rethrowRemote(e, s);
152
+ }
133
153
  }
134
154
 
135
155
  function rpath(fakePath) {
@@ -215,6 +235,9 @@ function patchFs(fakeRoot, port, token) {
215
235
 
216
236
  fs.accessSync = function patchedAccessSync(p, mode) {
217
237
  const s = abs(p);
238
+ // Same Windows bash fake as existsSync: newer pi probes the shell with
239
+ // accessSync, which would otherwise hit the local disk and fail.
240
+ if (process.platform === 'win32' && isBashPath(s)) return;
218
241
  if (!s || !isFakePath(s, fakeRoot)) return orig.accessSync(p, mode);
219
242
  try {
220
243
  const exists = httpGet(port, token, '/exists?path=' + encodeURIComponent(rpath(s))).trim() === '1';
@@ -229,17 +252,13 @@ function patchFs(fakeRoot, port, token) {
229
252
  fs.statSync = function patchedStatSync(p, opts) {
230
253
  const s = abs(p);
231
254
  if (!s || !isFakePath(s, fakeRoot)) return orig.statSync(p, opts);
232
- try {
233
- return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
234
- } catch (e) { rethrowRemote(e, s); }
255
+ return statRemoteSync(s, opts);
235
256
  };
236
257
 
237
258
  fs.lstatSync = function patchedLstatSync(p, opts) {
238
259
  const s = abs(p);
239
260
  if (!s || !isFakePath(s, fakeRoot)) return orig.lstatSync(p, opts);
240
- try {
241
- return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
242
- } catch (e) { rethrowRemote(e, s); }
261
+ return statRemoteSync(s, opts);
243
262
  };
244
263
 
245
264
  fs.writeFileSync = function patchedWriteFileSync(p, data, opts) {
@@ -359,7 +378,7 @@ function patchFs(fakeRoot, port, token) {
359
378
 
360
379
  // createReadStream: read entire file from remote, pipe as stream
361
380
  fs.createReadStream = function patchedCreateReadStream(p, opts) {
362
- const s = typeof p === 'string' ? abs(p) : null;
381
+ const s = abs(p);
363
382
  if (!s || !isFakePath(s, fakeRoot)) return orig.createReadStream(p, opts);
364
383
  const { Readable } = require('stream');
365
384
  const stream = new Readable({ read() {} });
@@ -372,7 +391,7 @@ function patchFs(fakeRoot, port, token) {
372
391
 
373
392
  // createWriteStream: buffer writes, POST on finish
374
393
  fs.createWriteStream = function patchedCreateWriteStream(p, opts) {
375
- const s = typeof p === 'string' ? abs(p) : null;
394
+ const s = abs(p);
376
395
  if (!s || !isFakePath(s, fakeRoot)) return orig.createWriteStream(p, opts);
377
396
  const { Writable } = require('stream');
378
397
  const chunks = [];
@@ -390,6 +409,7 @@ function patchFs(fakeRoot, port, token) {
390
409
 
391
410
  fs.promises.access = async function patchedAccess(p, mode) {
392
411
  const s = abs(p);
412
+ if (process.platform === 'win32' && isBashPath(s)) return;
393
413
  if (!s || !isFakePath(s, fakeRoot)) return origP.access(p, mode);
394
414
  try {
395
415
  const exists = (await httpGetAsync(port, token, '/exists?path=' + encodeURIComponent(rpath(s)))).trim() === '1';
@@ -402,7 +422,7 @@ function patchFs(fakeRoot, port, token) {
402
422
  };
403
423
 
404
424
  fs.promises.readFile = async function patchedReadFile(p, opts) {
405
- const s = typeof p === 'string' ? abs(p) : null;
425
+ const s = abs(p);
406
426
  if (!s || !isFakePath(s, fakeRoot)) return origP.readFile(p, opts);
407
427
  try {
408
428
  const body = await httpGetAsync(port, token, '/read?path=' + encodeURIComponent(rpath(s)));
@@ -416,7 +436,7 @@ function patchFs(fakeRoot, port, token) {
416
436
  };
417
437
 
418
438
  fs.promises.readdir = async function patchedReaddir(p, opts) {
419
- const s = typeof p === 'string' ? abs(p) : p.toString();
439
+ const s = abs(p) || p.toString();
420
440
  if (!isFakePath(s, fakeRoot)) return origP.readdir(p, opts);
421
441
  try {
422
442
  const withFileTypes = opts && opts.withFileTypes;
@@ -465,7 +485,7 @@ function patchFs(fakeRoot, port, token) {
465
485
  };
466
486
 
467
487
  fs.promises.writeFile = async function patchedWriteFile(p, data, opts) {
468
- const s = typeof p === 'string' ? abs(p) : null;
488
+ const s = abs(p);
469
489
  if (!s || !isFakePath(s, fakeRoot)) return origP.writeFile(p, data, opts);
470
490
  const content = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
471
491
  await new Promise(function(resolve, reject) {
@@ -517,7 +537,7 @@ function patchFs(fakeRoot, port, token) {
517
537
  };
518
538
 
519
539
  fs.promises.appendFile = async function patchedAppendFile(p, data, opts) {
520
- const s = typeof p === 'string' ? abs(p) : null;
540
+ const s = abs(p);
521
541
  if (!s || !isFakePath(s, fakeRoot)) return origP.appendFile(p, data, opts);
522
542
  const content = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
523
543
  const buf = Buffer.from(content, 'utf8');
@@ -614,7 +634,7 @@ function patchFs(fakeRoot, port, token) {
614
634
 
615
635
  // fs.promises.open: return a minimal FileHandle for reading
616
636
  fs.promises.open = async function patchedOpen(p, flags, mode) {
617
- const s = typeof p === 'string' ? abs(p) : null;
637
+ const s = abs(p);
618
638
  if (!s || !isFakePath(s, fakeRoot)) return origP.open(p, flags, mode);
619
639
  // Fetch the file content eagerly; return a minimal FileHandle-like object
620
640
  const content = await httpGetAsync(port, token, '/read?path=' + encodeURIComponent(rpath(s)));
@@ -646,7 +666,7 @@ function patchFs(fakeRoot, port, token) {
646
666
  const watchFilePollers = new Map();
647
667
 
648
668
  fs.watch = function patchedWatch(filename, opts, listener) {
649
- const s = abs(typeof filename === 'string' ? filename : null);
669
+ const s = abs(filename);
650
670
  if (!s || !isFakePath(s, fakeRoot)) return origWatch(filename, opts, listener);
651
671
 
652
672
  if (typeof opts === 'function') { listener = opts; opts = {}; }
@@ -711,7 +731,7 @@ function patchFs(fakeRoot, port, token) {
711
731
  };
712
732
 
713
733
  fs.watchFile = function patchedWatchFile(filename, opts, listener) {
714
- const s = abs(typeof filename === 'string' ? filename : null);
734
+ const s = abs(filename);
715
735
  if (!s || !isFakePath(s, fakeRoot)) {
716
736
  return origWatchFile ? origWatchFile(filename, opts, listener) : undefined;
717
737
  }
@@ -741,7 +761,7 @@ function patchFs(fakeRoot, port, token) {
741
761
  };
742
762
 
743
763
  fs.unwatchFile = function patchedUnwatchFile(filename, listener) {
744
- const s = abs(typeof filename === 'string' ? filename : null);
764
+ const s = abs(filename);
745
765
  if (!s || !isFakePath(s, fakeRoot)) {
746
766
  return origUnwatchFile ? origUnwatchFile(filename, listener) : undefined;
747
767
  }