@sharpe-jupyter/connect 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.
Files changed (2) hide show
  1. package/dist/index.js +220 -0
  2. package/package.json +30 -0
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { useState, useEffect } from "react";
5
+ import { render, Box, Text, useApp } from "ink";
6
+ import Spinner from "ink-spinner";
7
+ import { spawn } from "child_process";
8
+
9
+ // src/cloudflared.ts
10
+ import { createWriteStream, existsSync, mkdirSync, chmodSync } from "fs";
11
+ import { join } from "path";
12
+ import { homedir, platform, arch } from "os";
13
+ import { pipeline } from "stream/promises";
14
+ import { Readable } from "stream";
15
+ import { execSync } from "child_process";
16
+ var CLOUDFLARED_VERSION = "2026.2.0";
17
+ function getAssetInfo() {
18
+ const os = platform();
19
+ const cpu = arch();
20
+ if (os === "darwin") {
21
+ const base = cpu === "arm64" ? "cloudflared-darwin-arm64" : "cloudflared-darwin-amd64";
22
+ return { name: `${base}.tgz`, isTarball: true };
23
+ }
24
+ if (os === "linux") {
25
+ const base = cpu === "arm64" ? "cloudflared-linux-arm64" : "cloudflared-linux-amd64";
26
+ return { name: base, isTarball: false };
27
+ }
28
+ if (os === "win32") {
29
+ return { name: "cloudflared-windows-amd64.exe", isTarball: false };
30
+ }
31
+ throw new Error(`Unsupported platform: ${os}/${cpu}`);
32
+ }
33
+ function getBinDir() {
34
+ return join(homedir(), ".sharpe", "bin");
35
+ }
36
+ function getCloudflaredPath() {
37
+ const binName = platform() === "win32" ? "cloudflared.exe" : "cloudflared";
38
+ return join(getBinDir(), binName);
39
+ }
40
+ async function ensureCloudflared() {
41
+ const binPath = getCloudflaredPath();
42
+ if (existsSync(binPath)) {
43
+ return { path: binPath, downloaded: false };
44
+ }
45
+ const binDir = getBinDir();
46
+ mkdirSync(binDir, { recursive: true });
47
+ const asset = getAssetInfo();
48
+ const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${asset.name}`;
49
+ const response = await fetch(url, { redirect: "follow" });
50
+ if (!response.ok || !response.body) {
51
+ throw new Error(`Failed to download cloudflared: ${response.status} ${response.statusText}`);
52
+ }
53
+ if (asset.isTarball) {
54
+ const tgzPath = join(binDir, asset.name);
55
+ const fileStream = createWriteStream(tgzPath);
56
+ await pipeline(
57
+ Readable.fromWeb(response.body),
58
+ fileStream
59
+ );
60
+ execSync(`tar -xzf "${tgzPath}" -C "${binDir}" cloudflared`, { stdio: "ignore" });
61
+ execSync(`rm -f "${tgzPath}"`, { stdio: "ignore" });
62
+ } else {
63
+ const fileStream = createWriteStream(binPath);
64
+ await pipeline(
65
+ Readable.fromWeb(response.body),
66
+ fileStream
67
+ );
68
+ }
69
+ if (platform() !== "win32") {
70
+ chmodSync(binPath, 493);
71
+ }
72
+ return { path: binPath, downloaded: true };
73
+ }
74
+
75
+ // src/health.ts
76
+ async function checkJupyterHealth(port2) {
77
+ try {
78
+ const response = await fetch(`http://localhost:${port2}/api/status`, {
79
+ signal: AbortSignal.timeout(3e3)
80
+ });
81
+ return response.ok;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // src/index.tsx
88
+ import { jsx, jsxs } from "react/jsx-runtime";
89
+ function App({ token: token2, port: port2 }) {
90
+ const { exit } = useApp();
91
+ const [phase, setPhase] = useState({ step: "downloading" });
92
+ useEffect(() => {
93
+ let killed = false;
94
+ async function run() {
95
+ const { path: binPath } = await ensureCloudflared();
96
+ if (killed) return;
97
+ setPhase({ step: "health-check" });
98
+ const healthy = await checkJupyterHealth(port2);
99
+ if (killed) return;
100
+ const warning = healthy ? void 0 : `No JupyterHub at localhost:${port2} -- tunnel will connect but won't proxy traffic until JupyterHub is running.`;
101
+ setPhase({ step: "connecting", warning });
102
+ const child = spawn(binPath, ["tunnel", "run", "--token", token2], {
103
+ stdio: ["ignore", "pipe", "pipe"]
104
+ });
105
+ child.stderr?.on("data", (data) => {
106
+ const text = data.toString();
107
+ if (text.includes("Registered tunnel connection") || text.includes("INF")) {
108
+ setPhase({ step: "connected", warning });
109
+ }
110
+ });
111
+ child.stdout?.on("data", (data) => {
112
+ const text = data.toString();
113
+ if (text.includes("Registered tunnel connection")) {
114
+ setPhase({ step: "connected", warning });
115
+ }
116
+ });
117
+ child.on("error", (err) => {
118
+ if (!killed) {
119
+ setPhase({ step: "error", message: err.message });
120
+ exit();
121
+ }
122
+ });
123
+ child.on("exit", (code) => {
124
+ if (!killed) {
125
+ if (code !== 0 && code !== null) {
126
+ setPhase({ step: "error", message: `cloudflared exited with code ${code}` });
127
+ }
128
+ exit();
129
+ }
130
+ });
131
+ const cleanup = () => {
132
+ killed = true;
133
+ child.kill("SIGTERM");
134
+ };
135
+ process.on("SIGINT", cleanup);
136
+ process.on("SIGTERM", cleanup);
137
+ }
138
+ run().catch((err) => {
139
+ const message = err instanceof Error ? err.message : "Unknown error";
140
+ setPhase({ step: "error", message });
141
+ exit();
142
+ });
143
+ }, [token2, port2, exit]);
144
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
145
+ /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
146
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "sharpe-connect" }),
147
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " - Cloudflare Tunnel for JupyterHub" })
148
+ ] }),
149
+ phase.step === "downloading" && /* @__PURE__ */ jsxs(Box, { children: [
150
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
151
+ /* @__PURE__ */ jsx(Text, { children: " Downloading cloudflared..." })
152
+ ] }),
153
+ phase.step === "health-check" && /* @__PURE__ */ jsxs(Box, { children: [
154
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
155
+ /* @__PURE__ */ jsxs(Text, { children: [
156
+ " Checking JupyterHub at localhost:",
157
+ port2,
158
+ "..."
159
+ ] })
160
+ ] }),
161
+ phase.step === "connecting" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
162
+ /* @__PURE__ */ jsxs(Box, { children: [
163
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
164
+ /* @__PURE__ */ jsx(Text, { children: " Establishing tunnel connection..." })
165
+ ] }),
166
+ phase.warning && /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
167
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Warning: " }),
168
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: phase.warning })
169
+ ] })
170
+ ] }),
171
+ phase.step === "connected" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
172
+ /* @__PURE__ */ jsxs(Box, { children: [
173
+ /* @__PURE__ */ jsx(Text, { color: "green", bold: true, children: "Connected" }),
174
+ /* @__PURE__ */ jsx(Text, { children: " - Tunnel is active" })
175
+ ] }),
176
+ phase.warning && /* @__PURE__ */ jsxs(Box, { children: [
177
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Warning: " }),
178
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: phase.warning })
179
+ ] }),
180
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press Ctrl+C to disconnect" })
181
+ ] }),
182
+ phase.step === "error" && /* @__PURE__ */ jsxs(Box, { children: [
183
+ /* @__PURE__ */ jsxs(Text, { color: "red", bold: true, children: [
184
+ "Error:",
185
+ " "
186
+ ] }),
187
+ /* @__PURE__ */ jsx(Text, { children: phase.message })
188
+ ] })
189
+ ] });
190
+ }
191
+ function parseArgs(argv) {
192
+ const args = argv.slice(2);
193
+ let token2;
194
+ let port2 = 8e3;
195
+ for (let i = 0; i < args.length; i++) {
196
+ const arg = args[i];
197
+ if ((arg === "--token" || arg === "-t") && args[i + 1]) {
198
+ token2 = args[++i];
199
+ } else if ((arg === "--port" || arg === "-p") && args[i + 1]) {
200
+ port2 = parseInt(args[++i], 10);
201
+ } else if (arg === "--help" || arg === "-h") {
202
+ console.log(`
203
+ Usage: sharpe-connect --token <TOKEN> [--port <PORT>]
204
+
205
+ Options:
206
+ --token, -t Tunnel token from Sharpe dashboard (required)
207
+ --port, -p Local JupyterHub port (default: 8000)
208
+ --help, -h Show this help message
209
+ `);
210
+ process.exit(0);
211
+ }
212
+ }
213
+ if (!token2) {
214
+ console.error("Error: --token is required. Run with --help for usage.");
215
+ process.exit(1);
216
+ }
217
+ return { token: token2, port: port2 };
218
+ }
219
+ var { token, port } = parseArgs(process.argv);
220
+ render(/* @__PURE__ */ jsx(App, { token, port }));
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@sharpe-jupyter/connect",
3
+ "version": "0.1.0",
4
+ "description": "Connect a local JupyterHub to Sharpe via Cloudflare Tunnel",
5
+ "type": "module",
6
+ "bin": {
7
+ "sharpe-connect": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "build": "tsup src/index.tsx --format esm --clean",
17
+ "start": "tsx src/index.tsx"
18
+ },
19
+ "dependencies": {
20
+ "ink": "^6.0.0",
21
+ "ink-spinner": "^5.0.0",
22
+ "react": "^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.0.0",
26
+ "tsup": "^8.0.0",
27
+ "tsx": "^4.0.0",
28
+ "typescript": "^5.8.3"
29
+ }
30
+ }