diff --git a/.changeset/fix-cli-symlink-resolution.md b/.changeset/fix-cli-symlink-resolution.md new file mode 100644 index 0000000..3bd27f2 --- /dev/null +++ b/.changeset/fix-cli-symlink-resolution.md @@ -0,0 +1,5 @@ +--- +"@archastro/redline": patch +--- + +Fix symlink resolution in redline-pull, redline-watch, and redline-tail CLI scripts diff --git a/runtime/bin/redline-pull b/runtime/bin/redline-pull index 63459cb..a306b14 100755 --- a/runtime/bin/redline-pull +++ b/runtime/bin/redline-pull @@ -11,7 +11,15 @@ set -o pipefail PORT="${REDLINE_PORT:-7878}" BASE="http://127.0.0.1:$PORT" -HERE="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$0" +while [ -L "$SCRIPT" ]; do + link="$(readlink "$SCRIPT")" + case "$link" in + /*) SCRIPT="$link" ;; + *) SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$link" ;; + esac +done +HERE="$(cd "$(dirname "$SCRIPT")/.." && pwd)" HTTP_CLIENT="$HERE/lib/cli-http.js" ACK=1 ORIGIN="" diff --git a/runtime/bin/redline-tail b/runtime/bin/redline-tail index 5875d59..75b43eb 100755 --- a/runtime/bin/redline-tail +++ b/runtime/bin/redline-tail @@ -2,5 +2,13 @@ set -e set -o pipefail PORT="${REDLINE_PORT:-7878}" -HERE="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$0" +while [ -L "$SCRIPT" ]; do + link="$(readlink "$SCRIPT")" + case "$link" in + /*) SCRIPT="$link" ;; + *) SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$link" ;; + esac +done +HERE="$(cd "$(dirname "$SCRIPT")/.." && pwd)" node "$HERE/lib/cli-http.js" "$PORT" GET "/redlines" | (command -v jq >/dev/null && jq . || cat) diff --git a/runtime/bin/redline-watch b/runtime/bin/redline-watch index 953c7e7..9015af7 100755 --- a/runtime/bin/redline-watch +++ b/runtime/bin/redline-watch @@ -13,7 +13,15 @@ set -o pipefail PORT="${REDLINE_PORT:-7878}" BASE="http://127.0.0.1:$PORT" -HERE="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$0" +while [ -L "$SCRIPT" ]; do + link="$(readlink "$SCRIPT")" + case "$link" in + /*) SCRIPT="$link" ;; + *) SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$link" ;; + esac +done +HERE="$(cd "$(dirname "$SCRIPT")/.." && pwd)" HTTP_CLIENT="$HERE/lib/cli-http.js" INTERVAL=3 ORIGIN="" diff --git a/runtime/lib/sidecar-lifecycle.js b/runtime/lib/sidecar-lifecycle.js index 1888200..c186299 100644 --- a/runtime/lib/sidecar-lifecycle.js +++ b/runtime/lib/sidecar-lifecycle.js @@ -117,19 +117,29 @@ function removeLifecycleFile(file, label, expected) { } function readPrivateIdentity(file, label) { - const inspected = inspectLifecycleFile(file, label); - if (!inspected) return null; - const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); - try { - const opened = fs.fstatSync(fd); - if (opened.nlink !== 1 || opened.dev !== inspected.before.dev || opened.ino !== inspected.before.ino) { - throw new Error(`${label} changed before permission repair`); + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const inspected = inspectLifecycleFile(file, label); + if (!inspected) return null; + const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const opened = fs.fstatSync(fd); + if (opened.nlink !== 1 || opened.dev !== inspected.before.dev || opened.ino !== inspected.before.ino) { + throw new Error(`${label} changed before permission repair`); + } + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } + return inspected.contents; + } catch (error) { + if (attempt < 19 && (error.message.includes('has multiple links') || error.message.includes('changed before permission repair') || error.message.includes('changed while it was being opened'))) { + sleepSync(10); + continue; + } + throw error; } - fs.fchmodSync(fd, 0o600); - } finally { - fs.closeSync(fd); } - return inspected.contents; } function publishPrivateFile(file, contents) { diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js index 0d1b2d3..db0b013 100644 --- a/tests/cli-auth.test.js +++ b/tests/cli-auth.test.js @@ -240,3 +240,47 @@ test('redline skill uses the authenticated screenshot command instead of curl', assert.match(skill, /redline-screenshot /); assert.doesNotMatch(skill, /curl -o/); }); + +test('runtime bin scripts resolve module paths when invoked via symlinks', async (t) => { + const context = await startSidecar(t); + const symlinkBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'redline-symlink-bin-')); + t.after(() => fs.rmSync(symlinkBinDir, { recursive: true, force: true })); + + const scripts = ['redline-pull', 'redline-tail', 'redline-sidecar', 'redline-watch', 'redline-clear', 'redline-screenshot']; + for (const script of scripts) { + fs.symlinkSync(path.join(ROOT, 'runtime/bin', script), path.join(symlinkBinDir, script)); + } + + const pull = spawnSync(path.join(symlinkBinDir, 'redline-pull'), ['--no-ack'], { + cwd: symlinkBinDir, + env: { ...process.env, REDLINE_DIR: context.dir, REDLINE_PORT: String(context.port) }, + encoding: 'utf8', + timeout: 3000, + }); + assert.equal(pull.status, 0, pull.stderr || pull.stdout); + + const tail = spawnSync(path.join(symlinkBinDir, 'redline-tail'), [], { + cwd: symlinkBinDir, + env: { ...process.env, REDLINE_DIR: context.dir, REDLINE_PORT: String(context.port) }, + encoding: 'utf8', + timeout: 3000, + }); + assert.equal(tail.status, 0, tail.stderr || tail.stdout); + + const clear = spawnSync(path.join(symlinkBinDir, 'redline-clear'), [], { + cwd: symlinkBinDir, + env: { ...process.env, REDLINE_DIR: context.dir, REDLINE_PORT: String(context.port) }, + encoding: 'utf8', + timeout: 3000, + }); + assert.equal(clear.status, 0, clear.stderr || clear.stdout); + + const sidecarHelp = spawnSync(path.join(symlinkBinDir, 'redline-sidecar'), ['--help'], { + cwd: symlinkBinDir, + env: { ...process.env, REDLINE_DIR: context.dir, REDLINE_PORT: String(context.port) }, + encoding: 'utf8', + timeout: 3000, + }); + assert.equal(sidecarHelp.status, 0, sidecarHelp.stderr || sidecarHelp.stdout); + assert.match(sidecarHelp.stdout, /usage:\s*redline-sidecar/i); +});