pi-bridge 0.1.1
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/README.md +126 -0
- package/bin/pii.js +71 -0
- package/package.json +33 -0
- package/src/local/argv.js +45 -0
- package/src/local/child-process-patch.js +75 -0
- package/src/local/cleanup.js +56 -0
- package/src/local/fake-dir.js +51 -0
- package/src/local/fs-patch.js +639 -0
- package/src/local/http-client.js +130 -0
- package/src/local/path-mapper.js +54 -0
- package/src/local/preload.js +73 -0
- package/src/local/setup.js +79 -0
- package/src/local/ssh.js +20 -0
- package/src/local/tunnel.js +69 -0
- package/src/local/upload.js +81 -0
- package/src/remote/auth.js +33 -0
- package/src/remote/guard.js +49 -0
- package/src/remote/handlers.js +188 -0
- package/src/remote/index.js +43 -0
- package/src/shared/protocol.js +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# pi-bridge
|
|
2
|
+
|
|
3
|
+
Transparent filesystem bridge that makes [pi](https://github.com/badlogic/pi-mono) run natively on a remote machine — no internet required on the remote, no tools to install beyond Node.js.
|
|
4
|
+
|
|
5
|
+
Pi runs on your local machine (needs internet for auth and Claude API). Your project lives on a remote Linux machine. `pi-bridge` intercepts all of pi's filesystem calls and redirects them over SSH to a tiny HTTP server running on the remote. Pi thinks it's running locally — it finds `AGENTS.md`, skills, context files, and git state natively, without knowing they're remote.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- Node.js ≥ 18 on the **local** machine
|
|
10
|
+
- Node.js on the **remote** machine
|
|
11
|
+
- SSH access to the remote machine with key-based auth (no password prompt)
|
|
12
|
+
- `pi` installed locally
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g pi-bridge
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Use `pii` exactly like `pi`, adding `--ssh` to point at your remote project:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Absolute path
|
|
26
|
+
pii --ssh user@host:/root/projects/my-app
|
|
27
|
+
|
|
28
|
+
# With SSH alias (configured in ~/.ssh/config)
|
|
29
|
+
pii --ssh myserver:/root/projects/my-app
|
|
30
|
+
|
|
31
|
+
# Tilde path (expanded by remote shell)
|
|
32
|
+
pii --ssh user@host:~/projects/my-app
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Everything works natively — pi discovers `AGENTS.md`, skills, and context files on the remote without any extra flags or extensions.
|
|
36
|
+
|
|
37
|
+
## Updating
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm update -g pi-bridge
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How It Works
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Local machine Remote (Linux)
|
|
47
|
+
───────────────────────────── ──────────────────────
|
|
48
|
+
pii (bin/pii.js)
|
|
49
|
+
└─ node --require preload.js pi
|
|
50
|
+
├─ upload server bundle via SSH
|
|
51
|
+
├─ start HTTP server on remote
|
|
52
|
+
│ → prints PORT:TOKEN:PID to stdout
|
|
53
|
+
├─ SSH port forward localhost:PORT
|
|
54
|
+
├─ create fake local dir:
|
|
55
|
+
│ /tmp/pi-bridge/root/projects/my-app
|
|
56
|
+
├─ process.chdir(fakeLocalCwd)
|
|
57
|
+
├─ strip --ssh from process.argv
|
|
58
|
+
├─ patch fs.* (sync + promises) remote/index.js
|
|
59
|
+
└─ patch child_process.* listens on 127.0.0.1:PORT
|
|
60
|
+
validates token
|
|
61
|
+
pi loads — unaware of bridge restricts to remoteCwd
|
|
62
|
+
fs.readFileSync(...) ──GET──→ reads real remote files
|
|
63
|
+
fs.readdirSync(...) ──GET──→ ← returns data
|
|
64
|
+
spawn('git', ...) ──SSH──→ runs on remote
|
|
65
|
+
→ finds AGENTS.md ✓
|
|
66
|
+
→ finds skills ✓
|
|
67
|
+
→ git branch in footer ✓
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What Gets Patched
|
|
71
|
+
|
|
72
|
+
| API | Covered |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `fs` sync — read, write, stat, readdir, mkdir, rm, copy, rename, realpath, access, watch | ✓ |
|
|
75
|
+
| `fs.promises` — all async equivalents | ✓ |
|
|
76
|
+
| `fs/promises` module (ES module static imports) | ✓ |
|
|
77
|
+
| `child_process.spawn` / `spawnSync` — runs in remote cwd via SSH | ✓ |
|
|
78
|
+
| `fs.watch` / `fs.watchFile` — polls remote via HTTP (git branch updates) | ✓ |
|
|
79
|
+
|
|
80
|
+
## Security
|
|
81
|
+
|
|
82
|
+
| Layer | Protection |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Loopback only | HTTP server binds `127.0.0.1` — not reachable from the network |
|
|
85
|
+
| SSH tunnel | All HTTP traffic encrypted end-to-end |
|
|
86
|
+
| Random token | 32-byte random token required on every request |
|
|
87
|
+
| Path restriction | Server rejects paths outside `remoteCwd` (403) |
|
|
88
|
+
| Token in stdout | Token never appears in `ps aux` args |
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/zeflq/pi-bridge
|
|
94
|
+
cd pi-bridge
|
|
95
|
+
npm install
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Test against a real remote without installing pi:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
SSH_TARGET=user@host:/root/projects/my-app npm run dev
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Run tests:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm test
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Troubleshooting
|
|
111
|
+
|
|
112
|
+
**`Command failed: ssh … syntax error near unexpected token`**
|
|
113
|
+
Your SSH alias requires `BatchMode=yes`. Add to `~/.ssh/config`:
|
|
114
|
+
```
|
|
115
|
+
Host myserver
|
|
116
|
+
BatchMode yes
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Files show as empty / ENOENT**
|
|
120
|
+
The fake local directory (`/tmp/pi-bridge/…`) is intentionally empty — all reads go to the remote. If you see ENOENT, the bridge may not have started correctly. Check that Node.js is available on the remote (`node --version`).
|
|
121
|
+
|
|
122
|
+
**Branch name not updating**
|
|
123
|
+
The footer polls `.git` every second. Switching branches from outside a pi session is reflected within ~1s. If the branch is stuck, restart `pii`.
|
|
124
|
+
|
|
125
|
+
**Windows: `pii` can't find `pi`**
|
|
126
|
+
Ensure the OpenSSH client is enabled (Windows 10+: Settings → Apps → Optional Features → OpenSSH Client) and `pi` is installed globally (`npm install -g pi`).
|
package/bin/pii.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* pii — pi-bridge entry point installed as a global binary.
|
|
6
|
+
*
|
|
7
|
+
* Locates the `pi` CLI JS file and its bundled Node binary (if any), then
|
|
8
|
+
* respawns with --require preload.js so the bridge is active before pi loads.
|
|
9
|
+
*
|
|
10
|
+
* Resolution strategy (same on all platforms):
|
|
11
|
+
* 1. Get the wrapper script path from the system (which pi / Get-Command pi)
|
|
12
|
+
* 2. Derive basedir from the wrapper path
|
|
13
|
+
* 3. Find the actual CLI JS at basedir/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
|
|
14
|
+
* 4. Use basedir/node.exe if present (pi ships its own Node on some installs),
|
|
15
|
+
* otherwise fall back to the current Node binary
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
const preload = path.join(__dirname, '..', 'src', 'local', 'preload.js');
|
|
24
|
+
|
|
25
|
+
const PI_CLI_REL = path.join('node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'cli.js');
|
|
26
|
+
|
|
27
|
+
function getWrapperPath() {
|
|
28
|
+
if (os.platform() === 'win32') {
|
|
29
|
+
return execFileSync(
|
|
30
|
+
'powershell',
|
|
31
|
+
['-NoProfile', '-Command', '(Get-Command pi -ErrorAction Stop).Source'],
|
|
32
|
+
{ encoding: 'utf8' }
|
|
33
|
+
).trim();
|
|
34
|
+
}
|
|
35
|
+
return execFileSync('which', ['pi'], { encoding: 'utf8' }).trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findPiCli() {
|
|
39
|
+
const wrapper = getWrapperPath();
|
|
40
|
+
const basedir = path.dirname(wrapper);
|
|
41
|
+
|
|
42
|
+
const cli = path.join(basedir, PI_CLI_REL);
|
|
43
|
+
if (!fs.existsSync(cli)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Could not find pi CLI at ' + cli + '\n' +
|
|
46
|
+
'Make sure pi is installed: npm install -g @mariozechner/pi-coding-agent'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Prefer the Node binary bundled alongside pi (some installs ship node.exe)
|
|
51
|
+
const localNode = path.join(basedir, 'node.exe');
|
|
52
|
+
const nodeExe = fs.existsSync(localNode) ? localNode : process.execPath;
|
|
53
|
+
|
|
54
|
+
return { cli, nodeExe };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let cli, nodeExe;
|
|
58
|
+
try {
|
|
59
|
+
({ cli, nodeExe } = findPiCli());
|
|
60
|
+
} catch (err) {
|
|
61
|
+
process.stderr.write('pii: ' + err.message + '\n');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = spawnSync(
|
|
66
|
+
nodeExe,
|
|
67
|
+
['--require', preload, cli, ...process.argv.slice(2)],
|
|
68
|
+
{ stdio: 'inherit' }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
process.exit(result.status ?? 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-bridge",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Transparent filesystem bridge that makes pi run natively on a remote machine over SSH",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pii": "bin/pii.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest --run",
|
|
15
|
+
"dev": "node dev.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi-package",
|
|
22
|
+
"pi",
|
|
23
|
+
"pi-coding-agent",
|
|
24
|
+
"ssh",
|
|
25
|
+
"bridge",
|
|
26
|
+
"remote",
|
|
27
|
+
"filesystem"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^2.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse and strip --ssh user@host:/path from process.argv.
|
|
5
|
+
*
|
|
6
|
+
* Mutates process.argv in place, removing both '--ssh' and its value.
|
|
7
|
+
*
|
|
8
|
+
* @returns {{ remote: string, remoteCwd: string } | null}
|
|
9
|
+
* remote = 'user@host'
|
|
10
|
+
* remoteCwd = '/path' (or '~/path' — remote shell will expand it)
|
|
11
|
+
* Returns null if --ssh flag is not present.
|
|
12
|
+
*/
|
|
13
|
+
function parseAndStripSshArg() {
|
|
14
|
+
const argv = process.argv;
|
|
15
|
+
const idx = argv.indexOf('--ssh');
|
|
16
|
+
if (idx === -1) return null;
|
|
17
|
+
|
|
18
|
+
const value = argv[idx + 1];
|
|
19
|
+
if (!value) {
|
|
20
|
+
throw new Error('--ssh flag requires a value: user@host:/path');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// value format: user@host:/path or user@host:~/path
|
|
24
|
+
const colonIdx = value.indexOf(':');
|
|
25
|
+
if (colonIdx === -1) {
|
|
26
|
+
throw new Error('--ssh value must be in format user@host:/path, got: ' + value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const remote = value.slice(0, colonIdx);
|
|
30
|
+
const remoteCwd = value.slice(colonIdx + 1);
|
|
31
|
+
|
|
32
|
+
if (!remote) {
|
|
33
|
+
throw new Error('--ssh value missing host portion: ' + value);
|
|
34
|
+
}
|
|
35
|
+
if (!remoteCwd) {
|
|
36
|
+
throw new Error('--ssh value missing path portion: ' + value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Strip both '--ssh' and its value from argv
|
|
40
|
+
argv.splice(idx, 2);
|
|
41
|
+
|
|
42
|
+
return { remote, remoteCwd };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { parseAndStripSshArg };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cp = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { isFakePath, toRemotePath } = require('./path-mapper');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Patch child_process.spawn and child_process.spawnSync so that commands
|
|
9
|
+
* whose cwd lands inside fakeRoot are transparently redirected to run on
|
|
10
|
+
* the remote machine via SSH.
|
|
11
|
+
*
|
|
12
|
+
* Pi's bash tool uses spawn/spawnSync exclusively, so this covers all shell
|
|
13
|
+
* command execution (ls, cat, grep, npm run, etc.).
|
|
14
|
+
*
|
|
15
|
+
* @param {string} fakeRoot - Local fake root directory (real path, no symlinks)
|
|
16
|
+
* @param {string} remote - SSH target, e.g. 'user@host'
|
|
17
|
+
*/
|
|
18
|
+
function patchChildProcess(fakeRoot, remote) {
|
|
19
|
+
const origSpawn = cp.spawn.bind(cp);
|
|
20
|
+
const origSpawnSync = cp.spawnSync.bind(cp);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the effective cwd from spawn options.
|
|
24
|
+
* Falls back to process.cwd() when opts.cwd is not set.
|
|
25
|
+
*/
|
|
26
|
+
function effectiveCwd(opts) {
|
|
27
|
+
const c = opts && opts.cwd;
|
|
28
|
+
if (!c) return process.cwd();
|
|
29
|
+
return typeof c === 'string' ? path.resolve(c) : c.toString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the SSH-redirected command: cd to remote cwd, then run the
|
|
34
|
+
* original command with its arguments, all shell-quoted.
|
|
35
|
+
*/
|
|
36
|
+
function buildSshArgs(file, args, opts) {
|
|
37
|
+
const fakeCwd = effectiveCwd(opts);
|
|
38
|
+
const remoteCwd = toRemotePath(fakeCwd, fakeRoot);
|
|
39
|
+
|
|
40
|
+
// Reconstruct the command string with shell-safe quoting
|
|
41
|
+
const argStr = (args || [])
|
|
42
|
+
.map(function(a) { return "'" + String(a).replace(/'/g, "'\\''") + "'"; })
|
|
43
|
+
.join(' ');
|
|
44
|
+
const remoteCmd = 'cd ' + JSON.stringify(remoteCwd) + ' && ' + file + (argStr ? ' ' + argStr : '');
|
|
45
|
+
|
|
46
|
+
return ['-o', 'BatchMode=yes', remote, remoteCmd];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── spawn ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
cp.spawn = function patchedSpawn(file, args, opts) {
|
|
52
|
+
// Normalise overloaded signature: spawn(file) / spawn(file, opts) / spawn(file, args, opts)
|
|
53
|
+
if (!Array.isArray(args)) { opts = args; args = []; }
|
|
54
|
+
opts = opts || {};
|
|
55
|
+
|
|
56
|
+
const cwd = effectiveCwd(opts);
|
|
57
|
+
if (!isFakePath(cwd, fakeRoot)) return origSpawn(file, args, opts);
|
|
58
|
+
|
|
59
|
+
return origSpawn('ssh', buildSshArgs(file, args, opts), Object.assign({}, opts, { cwd: undefined }));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── spawnSync ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
cp.spawnSync = function patchedSpawnSync(file, args, opts) {
|
|
65
|
+
if (!Array.isArray(args)) { opts = args; args = []; }
|
|
66
|
+
opts = opts || {};
|
|
67
|
+
|
|
68
|
+
const cwd = effectiveCwd(opts);
|
|
69
|
+
if (!isFakePath(cwd, fakeRoot)) return origSpawnSync(file, args, opts);
|
|
70
|
+
|
|
71
|
+
return origSpawnSync('ssh', buildSshArgs(file, args, opts), Object.assign({}, opts, { cwd: undefined }));
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { patchChildProcess };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { removeFakeDir } = require('./fake-dir');
|
|
4
|
+
const { sshExecSync } = require('./ssh');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register a process.on('exit') handler that tears down the bridge session.
|
|
8
|
+
*
|
|
9
|
+
* Cleanup actions (best-effort — errors are suppressed):
|
|
10
|
+
* 1. Remove the fake local directory
|
|
11
|
+
* 2. Kill the SSH tunnel process
|
|
12
|
+
* 3. Kill the remote server process (by PID) and remove its log file
|
|
13
|
+
*
|
|
14
|
+
* Each session has a unique sessionId so its log file does not clash with
|
|
15
|
+
* other sessions targeting the same remote working directory.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} opts.fakeRoot - Local fake root path to remove
|
|
19
|
+
* @param {object} opts.tunnelProcess - SSH tunnel watchdog handle ({ kill() })
|
|
20
|
+
* @param {string} opts.remote - SSH target, e.g. 'user@host'
|
|
21
|
+
* @param {string} opts.sessionId - Random 8-char hex ID for this session
|
|
22
|
+
* @param {number} opts.remotePid - PID of the remote server process
|
|
23
|
+
*/
|
|
24
|
+
function registerCleanup({ fakeRoot, tunnelProcess, remote, sessionId, remotePid }) {
|
|
25
|
+
const remoteLog = sessionId ? '/tmp/pi-bridge-out-' + sessionId + '.txt' : null;
|
|
26
|
+
|
|
27
|
+
function cleanup() {
|
|
28
|
+
// 1. Remove fake local directory
|
|
29
|
+
if (fakeRoot) {
|
|
30
|
+
removeFakeDir(fakeRoot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Kill tunnel watchdog
|
|
34
|
+
if (tunnelProcess) {
|
|
35
|
+
try {
|
|
36
|
+
if (typeof tunnelProcess.kill === 'function') tunnelProcess.kill();
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Kill the specific remote server process and clean up its log file
|
|
41
|
+
if (remote) {
|
|
42
|
+
try {
|
|
43
|
+
let cmd = '';
|
|
44
|
+
if (remotePid) cmd += 'kill ' + remotePid + ' 2>/dev/null; ';
|
|
45
|
+
if (remoteLog) cmd += 'rm -f ' + remoteLog;
|
|
46
|
+
if (cmd) sshExecSync(remote, cmd);
|
|
47
|
+
} catch (_) {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
process.on('exit', cleanup);
|
|
52
|
+
process.on('SIGINT', () => process.exit(130));
|
|
53
|
+
process.on('SIGTERM', () => process.exit(143));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { registerCleanup };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create the fake local directory skeleton for the given remote path.
|
|
9
|
+
* The structure mirrors the remote path so that path-mapper can convert
|
|
10
|
+
* between fake local paths and remote paths deterministically.
|
|
11
|
+
*
|
|
12
|
+
* Example:
|
|
13
|
+
* remoteCwd = '/root/project-x'
|
|
14
|
+
* fakeRoot = '/tmp/pi-bridge'
|
|
15
|
+
* fakeLocalCwd = '/tmp/pi-bridge/root/project-x'
|
|
16
|
+
*
|
|
17
|
+
* @param {string} remoteCwd - Absolute remote path, e.g. '/root/project-x'
|
|
18
|
+
* @returns {{ fakeRoot: string, fakeLocalCwd: string }}
|
|
19
|
+
*/
|
|
20
|
+
function createFakeDir(remoteCwd) {
|
|
21
|
+
const fakeRootRaw = path.join(os.tmpdir(), 'pi-bridge');
|
|
22
|
+
|
|
23
|
+
// Build the local mirror path from the remote path segments
|
|
24
|
+
const segments = remoteCwd.split('/').filter(Boolean);
|
|
25
|
+
const fakeLocalCwdRaw = path.join(fakeRootRaw, ...segments);
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(fakeLocalCwdRaw, { recursive: true });
|
|
28
|
+
|
|
29
|
+
// Resolve symlinks so the prefix matches what process.cwd() returns after
|
|
30
|
+
// chdir — on macOS /var/folders/… is a symlink to /private/var/folders/…
|
|
31
|
+
const fakeRoot = fs.realpathSync(fakeRootRaw);
|
|
32
|
+
const fakeLocalCwd = fs.realpathSync(fakeLocalCwdRaw);
|
|
33
|
+
|
|
34
|
+
return { fakeRoot, fakeLocalCwd };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove the fake root directory and all its contents.
|
|
39
|
+
* Safe to call even if the directory does not exist.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} fakeRoot - Path returned by createFakeDir
|
|
42
|
+
*/
|
|
43
|
+
function removeFakeDir(fakeRoot) {
|
|
44
|
+
try {
|
|
45
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
46
|
+
} catch (_) {
|
|
47
|
+
// Best-effort removal — do not throw during cleanup
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createFakeDir, removeFakeDir };
|