loophole-im 0.1.0

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.
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { homedir, platform, arch } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const REPO = process.env.LOOPHOLE_REPO ?? "Sir-Goose/loophole";
10
+ const VERSION = await getVersion();
11
+
12
+ const TARGETS = {
13
+ "darwin-arm64": "loophole-darwin-arm64",
14
+ "darwin-x64": "loophole-darwin-x64",
15
+ "linux-arm64": "loophole-linux-arm64",
16
+ "linux-x64": "loophole-linux-x64",
17
+ "windows-x64.exe": "loophole-windows-x64.exe",
18
+ };
19
+
20
+ function getSuffix() {
21
+ const os = platform();
22
+ const a = arch();
23
+ let key;
24
+ if (os === "darwin") key = `darwin-${a === "arm64" ? "arm64" : "x64"}`;
25
+ else if (os === "linux") key = `linux-${a === "arm64" ? "arm64" : "x64"}`;
26
+ else if (os === "win32" && a === "x64") key = "windows-x64.exe";
27
+ else {
28
+ console.error(`unsupported platform ${os}/${a} — see https://github.com/${REPO}/releases`);
29
+ process.exit(1);
30
+ }
31
+ if (!(key in TARGETS)) {
32
+ console.error(`unsupported target ${key}`);
33
+ process.exit(1);
34
+ }
35
+ return key;
36
+ }
37
+
38
+ async function getVersion() {
39
+ try {
40
+ const pkgPath = join(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
41
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
42
+ return pkg.version ?? "0.0.0";
43
+ } catch {
44
+ return process.env.LOOPHOLE_VERSION ?? "0.0.0";
45
+ }
46
+ }
47
+
48
+ function binName(suffix) {
49
+ return TARGETS[suffix];
50
+ }
51
+
52
+ function localBinPaths(suffix) {
53
+ const name = binName(suffix);
54
+ const home = homedir();
55
+ return [
56
+ // bundled next to shim (if published with binaries)
57
+ join(fileURLToPath(new URL(".", import.meta.url)), name),
58
+ // dist from bun build --compile (local dev)
59
+ join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..", "dist", name),
60
+ // cached in ~/.loophole/bin (where install.sh and cloudflared live)
61
+ join(home, ".loophole", "bin", name),
62
+ // also check without exe on windows fallback
63
+ join(home, ".loophole", "bin", name.replace(/\.exe$/, "")),
64
+ ];
65
+ }
66
+
67
+ function findLocalBin(suffix) {
68
+ for (const p of localBinPaths(suffix)) {
69
+ if (existsSync(p)) return p;
70
+ }
71
+ // also check PATH
72
+ const pathEnv = process.env.PATH ?? "";
73
+ for (const dir of pathEnv.split(":")) {
74
+ const cand = join(dir, "loophole");
75
+ if (existsSync(cand)) return cand;
76
+ const cand2 = join(dir, binName(suffix));
77
+ if (existsSync(cand2)) return cand2;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ async function downloadBinary(suffix) {
83
+ const name = binName(suffix);
84
+ const version = VERSION;
85
+ const isPrivate = true; // repo is private until flipped public
86
+ const ghToken = process.env.GH_TOKEN;
87
+ const cacheDir = join(homedir(), ".loophole", "bin");
88
+ mkdirSync(cacheDir, { recursive: true });
89
+ const dest = join(cacheDir, name);
90
+ const shaDest = join(cacheDir, "SHA256SUMS");
91
+
92
+ // Use GitHub API if GH_TOKEN set (required for private repo), else try public download
93
+ const apiBase = "https://api.github.com";
94
+ const headers = { "Accept": "application/vnd.github+json" };
95
+ if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
96
+
97
+ console.error(`downloading loophole ${version} (${suffix}) …`);
98
+
99
+ let binaryUrl;
100
+ let shaUrl;
101
+ if (ghToken) {
102
+ // api mode — fetch release json to get asset ids
103
+ const ref = version && version !== "0.0.0" ? `tags/v${version}` : "latest";
104
+ const releaseUrl = `${apiBase}/repos/${REPO}/releases/${ref}`;
105
+ const res = await fetch(releaseUrl, { headers });
106
+ if (!res.ok) {
107
+ console.error(`could not fetch release ${ref}: ${res.status} ${await res.text()}`);
108
+ process.exit(1);
109
+ }
110
+ const rel = await res.json();
111
+ const findAsset = (n) => rel.assets?.find((a) => a.name === n);
112
+ const asset = findAsset(name);
113
+ const shaAsset = findAsset("SHA256SUMS");
114
+ if (!asset || !shaAsset) {
115
+ console.error(`asset ${name} or SHA256SUMS not found in release ${rel.tag_name}`);
116
+ process.exit(1);
117
+ }
118
+ binaryUrl = asset.url;
119
+ shaUrl = shaAsset.url;
120
+ // GitHub API requires Accept: application/octet-stream for binary
121
+ await downloadFile(binaryUrl, dest, { ...headers, Accept: "application/octet-stream" });
122
+ await downloadFile(shaUrl, shaDest, headers);
123
+ } else {
124
+ // public mode — direct download (will 404 while repo private)
125
+ const base = version && version !== "0.0.0" && version !== "0.0.0-dev"
126
+ ? `https://github.com/${REPO}/releases/download/v${version}`
127
+ : `https://github.com/${REPO}/releases/latest/download`;
128
+ binaryUrl = `${base}/${name}`;
129
+ shaUrl = `${base}/SHA256SUMS`;
130
+ try {
131
+ await downloadFile(binaryUrl, dest, {});
132
+ await downloadFile(shaUrl, shaDest, {});
133
+ } catch (e) {
134
+ console.error(`could not download ${name} — repo is private, set GH_TOKEN to download`);
135
+ console.error(` export GH_TOKEN=ghp_...`);
136
+ console.error(` npx loophole-im link # then tunnel`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ // verify checksum
142
+ try {
143
+ const shas = readFileSync(shaDest, "utf8");
144
+ const wanted = shas.split("\n").find((l) => l.includes(name));
145
+ if (!wanted) throw new Error(`no checksum for ${name}`);
146
+ const expected = wanted.split(/\s+/)[0];
147
+ const data = readFileSync(dest);
148
+ const actual = createHash("sha256").update(data).digest("hex");
149
+ if (actual !== expected) {
150
+ console.error(`checksum mismatch for ${name}: expected ${expected}, got ${actual}`);
151
+ process.exit(1);
152
+ }
153
+ } catch (e) {
154
+ console.error(`checksum verification failed: ${e.message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ chmodSync(dest, 0o755);
159
+ return dest;
160
+ }
161
+
162
+ async function downloadFile(url, dest, headers) {
163
+ const res = await fetch(url, { headers, redirect: "follow" });
164
+ if (!res.ok) throw new Error(`fetch ${url}: ${res.status} ${res.statusText}`);
165
+ const buf = Buffer.from(await res.arrayBuffer());
166
+ writeFileSync(dest, buf, { mode: 0o755 });
167
+ if (!existsSync(dest)) throw new Error(`failed to write ${dest}`);
168
+ }
169
+
170
+ async function main() {
171
+ const suffix = getSuffix();
172
+ let bin = findLocalBin(suffix);
173
+
174
+ // handle npx loophole-im with no args → default to tunnel (like npx t3)
175
+ let args = process.argv.slice(2);
176
+ if (args.length === 0) args = ["tunnel"];
177
+
178
+ if (!bin) {
179
+ // try to download
180
+ bin = await downloadBinary(suffix);
181
+ }
182
+
183
+ // spawn the real binary
184
+ const child = spawn(bin, args, { stdio: "inherit" });
185
+ child.on("exit", (code, signal) => {
186
+ if (signal) process.kill(process.pid, signal);
187
+ else process.exit(code ?? 0);
188
+ });
189
+ child.on("error", (err) => {
190
+ console.error(`failed to spawn ${bin}: ${err.message}`);
191
+ process.exit(1);
192
+ });
193
+ }
194
+
195
+ await main();
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { homedir, platform, arch } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const REPO = process.env.LOOPHOLE_REPO ?? "Sir-Goose/loophole";
10
+ const VERSION = await getVersion();
11
+
12
+ const TARGETS = {
13
+ "darwin-arm64": "loophole-darwin-arm64",
14
+ "darwin-x64": "loophole-darwin-x64",
15
+ "linux-arm64": "loophole-linux-arm64",
16
+ "linux-x64": "loophole-linux-x64",
17
+ "windows-x64.exe": "loophole-windows-x64.exe",
18
+ };
19
+
20
+ function getSuffix() {
21
+ const os = platform();
22
+ const a = arch();
23
+ let key;
24
+ if (os === "darwin") key = `darwin-${a === "arm64" ? "arm64" : "x64"}`;
25
+ else if (os === "linux") key = `linux-${a === "arm64" ? "arm64" : "x64"}`;
26
+ else if (os === "win32" && a === "x64") key = "windows-x64.exe";
27
+ else {
28
+ console.error(`unsupported platform ${os}/${a} — see https://github.com/${REPO}/releases`);
29
+ process.exit(1);
30
+ }
31
+ if (!(key in TARGETS)) {
32
+ console.error(`unsupported target ${key}`);
33
+ process.exit(1);
34
+ }
35
+ return key;
36
+ }
37
+
38
+ async function getVersion() {
39
+ try {
40
+ const pkgPath = join(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
41
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
42
+ return pkg.version ?? "0.0.0";
43
+ } catch {
44
+ return process.env.LOOPHOLE_VERSION ?? "0.0.0";
45
+ }
46
+ }
47
+
48
+ function binName(suffix) {
49
+ return TARGETS[suffix];
50
+ }
51
+
52
+ function localBinPaths(suffix) {
53
+ const name = binName(suffix);
54
+ const home = homedir();
55
+ return [
56
+ // bundled next to shim (if published with binaries)
57
+ join(fileURLToPath(new URL(".", import.meta.url)), name),
58
+ // dist from bun build --compile (local dev)
59
+ join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..", "dist", name),
60
+ // cached in ~/.loophole/bin (where install.sh and cloudflared live)
61
+ join(home, ".loophole", "bin", name),
62
+ // also check without exe on windows fallback
63
+ join(home, ".loophole", "bin", name.replace(/\.exe$/, "")),
64
+ ];
65
+ }
66
+
67
+ function findLocalBin(suffix) {
68
+ for (const p of localBinPaths(suffix)) {
69
+ if (existsSync(p)) return p;
70
+ }
71
+ // also check PATH
72
+ const pathEnv = process.env.PATH ?? "";
73
+ for (const dir of pathEnv.split(":")) {
74
+ const cand = join(dir, "loophole");
75
+ if (existsSync(cand)) return cand;
76
+ const cand2 = join(dir, binName(suffix));
77
+ if (existsSync(cand2)) return cand2;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ async function downloadBinary(suffix) {
83
+ const name = binName(suffix);
84
+ const version = VERSION;
85
+ const isPrivate = true; // repo is private until flipped public
86
+ const ghToken = process.env.GH_TOKEN;
87
+ const cacheDir = join(homedir(), ".loophole", "bin");
88
+ mkdirSync(cacheDir, { recursive: true });
89
+ const dest = join(cacheDir, name);
90
+ const shaDest = join(cacheDir, "SHA256SUMS");
91
+
92
+ // Use GitHub API if GH_TOKEN set (required for private repo), else try public download
93
+ const apiBase = "https://api.github.com";
94
+ const headers = { "Accept": "application/vnd.github+json" };
95
+ if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
96
+
97
+ console.error(`downloading loophole ${version} (${suffix}) …`);
98
+
99
+ let binaryUrl;
100
+ let shaUrl;
101
+ if (ghToken) {
102
+ // api mode — fetch release json to get asset ids
103
+ const ref = version && version !== "0.0.0" ? `tags/v${version}` : "latest";
104
+ const releaseUrl = `${apiBase}/repos/${REPO}/releases/${ref}`;
105
+ const res = await fetch(releaseUrl, { headers });
106
+ if (!res.ok) {
107
+ console.error(`could not fetch release ${ref}: ${res.status} ${await res.text()}`);
108
+ process.exit(1);
109
+ }
110
+ const rel = await res.json();
111
+ const findAsset = (n) => rel.assets?.find((a) => a.name === n);
112
+ const asset = findAsset(name);
113
+ const shaAsset = findAsset("SHA256SUMS");
114
+ if (!asset || !shaAsset) {
115
+ console.error(`asset ${name} or SHA256SUMS not found in release ${rel.tag_name}`);
116
+ process.exit(1);
117
+ }
118
+ binaryUrl = asset.url;
119
+ shaUrl = shaAsset.url;
120
+ // GitHub API requires Accept: application/octet-stream for binary
121
+ await downloadFile(binaryUrl, dest, { ...headers, Accept: "application/octet-stream" });
122
+ await downloadFile(shaUrl, shaDest, headers);
123
+ } else {
124
+ // public mode — direct download (will 404 while repo private)
125
+ const base = version && version !== "0.0.0" && version !== "0.0.0-dev"
126
+ ? `https://github.com/${REPO}/releases/download/v${version}`
127
+ : `https://github.com/${REPO}/releases/latest/download`;
128
+ binaryUrl = `${base}/${name}`;
129
+ shaUrl = `${base}/SHA256SUMS`;
130
+ try {
131
+ await downloadFile(binaryUrl, dest, {});
132
+ await downloadFile(shaUrl, shaDest, {});
133
+ } catch (e) {
134
+ console.error(`could not download ${name} — repo is private, set GH_TOKEN to download`);
135
+ console.error(` export GH_TOKEN=ghp_...`);
136
+ console.error(` npx loophole-im link # then tunnel`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ // verify checksum
142
+ try {
143
+ const shas = readFileSync(shaDest, "utf8");
144
+ const wanted = shas.split("\n").find((l) => l.includes(name));
145
+ if (!wanted) throw new Error(`no checksum for ${name}`);
146
+ const expected = wanted.split(/\s+/)[0];
147
+ const data = readFileSync(dest);
148
+ const actual = createHash("sha256").update(data).digest("hex");
149
+ if (actual !== expected) {
150
+ console.error(`checksum mismatch for ${name}: expected ${expected}, got ${actual}`);
151
+ process.exit(1);
152
+ }
153
+ } catch (e) {
154
+ console.error(`checksum verification failed: ${e.message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ chmodSync(dest, 0o755);
159
+ return dest;
160
+ }
161
+
162
+ async function downloadFile(url, dest, headers) {
163
+ const res = await fetch(url, { headers, redirect: "follow" });
164
+ if (!res.ok) throw new Error(`fetch ${url}: ${res.status} ${res.statusText}`);
165
+ const buf = Buffer.from(await res.arrayBuffer());
166
+ writeFileSync(dest, buf, { mode: 0o755 });
167
+ if (!existsSync(dest)) throw new Error(`failed to write ${dest}`);
168
+ }
169
+
170
+ async function main() {
171
+ const suffix = getSuffix();
172
+ let bin = findLocalBin(suffix);
173
+
174
+ // handle npx loophole-im with no args → default to tunnel (like npx t3)
175
+ let args = process.argv.slice(2);
176
+ if (args.length === 0) args = ["tunnel"];
177
+
178
+ if (!bin) {
179
+ // try to download
180
+ bin = await downloadBinary(suffix);
181
+ }
182
+
183
+ // spawn the real binary
184
+ const child = spawn(bin, args, { stdio: "inherit" });
185
+ child.on("exit", (code, signal) => {
186
+ if (signal) process.kill(process.pid, signal);
187
+ else process.exit(code ?? 0);
188
+ });
189
+ child.on("error", (err) => {
190
+ console.error(`failed to spawn ${bin}: ${err.message}`);
191
+ process.exit(1);
192
+ });
193
+ }
194
+
195
+ await main();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "loophole-im",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Tunnel local web apps so they're reachable from any signed-in instance — private Cloudflare Tunnels",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Sir-Goose/loophole"
10
+ },
11
+ "bin": {
12
+ "loophole-im": "bin/loophole-im.js",
13
+ "loophole": "bin/loophole.js"
14
+ },
15
+ "files": [
16
+ "bin"
17
+ ],
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "dev": "bun run src/index.ts",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "dependencies": {},
30
+ "devDependencies": {
31
+ "@loophole/contracts": "workspace:*",
32
+ "@types/bun": "latest",
33
+ "jose": "^5.9.6",
34
+ "typescript": "^5.7.2",
35
+ "zod": "^3.24.1"
36
+ }
37
+ }