@sharpe-jupyter/connect 0.1.0 → 0.3.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 +683 -105
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.tsx
4
- import { useState, useEffect } from "react";
5
- import { render, Box, Text, useApp } from "ink";
4
+ import { render } from "ink";
5
+
6
+ // src/app.tsx
7
+ import { useState, useEffect, useRef, useCallback } from "react";
8
+ import { Box, Text, useApp, useInput } from "ink";
6
9
  import Spinner from "ink-spinner";
7
- import { spawn } from "child_process";
10
+ import { spawn as spawn2 } from "child_process";
8
11
 
9
12
  // src/cloudflared.ts
10
- import { createWriteStream, existsSync, mkdirSync, chmodSync } from "fs";
13
+ import { createWriteStream, existsSync, mkdirSync, chmodSync, unlinkSync } from "fs";
11
14
  import { join } from "path";
12
15
  import { homedir, platform, arch } from "os";
13
16
  import { pipeline } from "stream/promises";
@@ -58,7 +61,7 @@ async function ensureCloudflared() {
58
61
  fileStream
59
62
  );
60
63
  execSync(`tar -xzf "${tgzPath}" -C "${binDir}" cloudflared`, { stdio: "ignore" });
61
- execSync(`rm -f "${tgzPath}"`, { stdio: "ignore" });
64
+ unlinkSync(tgzPath);
62
65
  } else {
63
66
  const fileStream = createWriteStream(binPath);
64
67
  await pipeline(
@@ -72,6 +75,53 @@ async function ensureCloudflared() {
72
75
  return { path: binPath, downloaded: true };
73
76
  }
74
77
 
78
+ // src/ripgrep.ts
79
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, chmodSync as chmodSync2, unlinkSync as unlinkSync2, createWriteStream as createWriteStream2 } from "fs";
80
+ import { join as join2 } from "path";
81
+ import { homedir as homedir2, arch as arch2 } from "os";
82
+ import { pipeline as pipeline2 } from "stream/promises";
83
+ import { Readable as Readable2 } from "stream";
84
+ import { execSync as execSync2 } from "child_process";
85
+ var RIPGREP_VERSION = "15.1.0";
86
+ function getAssetName() {
87
+ const cpu = arch2();
88
+ const triple = cpu === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
89
+ return `ripgrep-${RIPGREP_VERSION}-${triple}.tar.gz`;
90
+ }
91
+ function getBinDir2() {
92
+ return join2(homedir2(), ".sharpe", "bin");
93
+ }
94
+ function getRipgrepPath() {
95
+ return join2(getBinDir2(), "rg");
96
+ }
97
+ async function ensureRipgrep() {
98
+ const binPath = getRipgrepPath();
99
+ if (existsSync2(binPath)) {
100
+ return { path: binPath, downloaded: false };
101
+ }
102
+ const binDir = getBinDir2();
103
+ mkdirSync2(binDir, { recursive: true });
104
+ const assetName = getAssetName();
105
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${assetName}`;
106
+ const response = await fetch(url, { redirect: "follow" });
107
+ if (!response.ok || !response.body) {
108
+ throw new Error(`Failed to download ripgrep: ${response.status} ${response.statusText}`);
109
+ }
110
+ const tgzPath = join2(binDir, assetName);
111
+ const fileStream = createWriteStream2(tgzPath);
112
+ await pipeline2(
113
+ Readable2.fromWeb(response.body),
114
+ fileStream
115
+ );
116
+ const dirName = assetName.replace(".tar.gz", "");
117
+ execSync2(`tar -xzf "${tgzPath}" -C "${binDir}" "${dirName}/rg"`, { stdio: "ignore" });
118
+ execSync2(`mv "${join2(binDir, dirName, "rg")}" "${binPath}"`, { stdio: "ignore" });
119
+ unlinkSync2(tgzPath);
120
+ execSync2(`rm -rf "${join2(binDir, dirName)}"`, { stdio: "ignore" });
121
+ chmodSync2(binPath, 493);
122
+ return { path: binPath, downloaded: true };
123
+ }
124
+
75
125
  // src/health.ts
76
126
  async function checkJupyterHealth(port2) {
77
127
  try {
@@ -84,137 +134,665 @@ async function checkJupyterHealth(port2) {
84
134
  }
85
135
  }
86
136
 
87
- // src/index.tsx
137
+ // src/jupyter.ts
138
+ import { execSync as execSync3, spawn } from "child_process";
139
+ import { existsSync as existsSync3 } from "fs";
140
+ import { join as join3 } from "path";
141
+ import { homedir as homedir3, platform as platform2 } from "os";
142
+ var SHARPE_DIR = join3(homedir3(), ".sharpe");
143
+ var VENV_DIR = join3(SHARPE_DIR, "venv");
144
+ var SHARPE_BIN_DIR = join3(SHARPE_DIR, "bin");
145
+ var IS_WIN = platform2() === "win32";
146
+ var BIN_DIR = IS_WIN ? "Scripts" : "bin";
147
+ function venvBin(name) {
148
+ return join3(VENV_DIR, BIN_DIR, IS_WIN ? `${name}.exe` : name);
149
+ }
150
+ function findPython() {
151
+ for (const cmd of ["python3", "python"]) {
152
+ try {
153
+ const version = execSync3(`${cmd} --version`, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
154
+ if (version.startsWith("Python 3")) return cmd;
155
+ } catch {
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ function venvExists() {
161
+ return existsSync3(venvBin("python"));
162
+ }
163
+ function createVenv(pythonCmd) {
164
+ execSync3(`${pythonCmd} -m venv "${VENV_DIR}"`, { stdio: "ignore" });
165
+ }
166
+ function installPackages(packages) {
167
+ return spawn(venvBin("pip"), ["install", "--quiet", ...packages], {
168
+ stdio: ["ignore", "pipe", "pipe"]
169
+ });
170
+ }
171
+ function startJupyter(port2) {
172
+ const env = { ...process.env, PATH: `${SHARPE_BIN_DIR}:${process.env.PATH ?? ""}` };
173
+ return spawn(
174
+ venvBin("jupyter"),
175
+ [
176
+ "notebook",
177
+ "--port",
178
+ String(port2),
179
+ "--no-browser",
180
+ "--ServerApp.token=",
181
+ "--ServerApp.password=",
182
+ "--ServerApp.allow_remote_access=True",
183
+ "--ServerApp.disable_check_xsrf=True",
184
+ '--ServerApp.jpserver_extensions={"sharpe_log_handler.handler": true}'
185
+ ],
186
+ { stdio: ["ignore", "pipe", "pipe"], env }
187
+ );
188
+ }
189
+ function parseJupyterLine(line) {
190
+ if (/Saving file at\s+(\S+)/.test(line)) {
191
+ const match = line.match(/Saving file at\s+(\S+)/);
192
+ if (match) {
193
+ const filename = match[1].split("/").pop() ?? match[1];
194
+ return `Notebook saved: ${filename}`;
195
+ }
196
+ }
197
+ if (/Kernel started/i.test(line)) {
198
+ return "Kernel started";
199
+ }
200
+ if (/KernelRestarter:\s*restarting/i.test(line)) {
201
+ return "Kernel restarted";
202
+ }
203
+ if (/\b(ERROR|CRITICAL)\b/.test(line)) {
204
+ const cleaned = line.replace(/^\[.*?\]\s*/, "").replace(/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s*/, "").replace(/^(ERROR|CRITICAL)\s*[|:]\s*/i, "").trim();
205
+ return cleaned || null;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // src/app.tsx
88
211
  import { jsx, jsxs } from "react/jsx-runtime";
89
- function App({ token: token2, port: port2 }) {
212
+ var MAX_EVENTS = 10;
213
+ var LOGO = ` ..
214
+ .:
215
+ .@.
216
+ .@*
217
+ .@@+.
218
+ .@@@=
219
+ .@@@@*
220
+ .@@@@@@.
221
+ .@@@@@@@#.
222
+ .@@@@@@@@@@-.
223
+ .=@@@@@@@@@@.
224
+ .#@@@@@@@.
225
+ .@@@@@@.
226
+ *@@@@.
227
+ -@@@.
228
+ .+@@.
229
+ *@.
230
+ .@.
231
+ :.
232
+ ..`;
233
+ function useUptime(startedAt) {
234
+ const [now, setNow] = useState(Date.now());
235
+ useEffect(() => {
236
+ if (startedAt === null) return;
237
+ const id = setInterval(() => setNow(Date.now()), 1e3);
238
+ return () => clearInterval(id);
239
+ }, [startedAt]);
240
+ if (startedAt === null) return "0s";
241
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1e3));
242
+ const m = Math.floor(elapsed / 60);
243
+ const s = elapsed % 60;
244
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
245
+ }
246
+ function formatTime() {
247
+ const d = /* @__PURE__ */ new Date();
248
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
249
+ }
250
+ function App({ connectionCode: connectionCode2, port: port2 }) {
90
251
  const { exit } = useApp();
91
- const [phase, setPhase] = useState({ step: "downloading" });
252
+ const [phase, setPhase] = useState(connectionCode2 ? "starting" : "prompting");
253
+ const [setupStep, setSetupStep] = useState(connectionCode2 ? "active" : "pending");
254
+ const [jupyterStep, setJupyterStep] = useState("pending");
255
+ const [tunnelStep, setTunnelStep] = useState("pending");
256
+ const [jupyterRunning, setJupyterRunning] = useState(false);
257
+ const [tunnelConnected, setTunnelConnected] = useState(false);
258
+ const [events, setEvents] = useState([]);
259
+ const [startedAt, setStartedAt] = useState(null);
260
+ const [error, setError] = useState(null);
261
+ const [tokenInput, setTokenInput] = useState("");
262
+ const [activeToken, setActiveToken] = useState(connectionCode2);
263
+ const didWeSpawnJupyter = useRef(false);
264
+ const jupyterProc = useRef(null);
265
+ const tunnelProc = useRef(null);
266
+ const cleanedUp = useRef(false);
267
+ const uptime = useUptime(startedAt);
268
+ const pushEvent = useCallback((message) => {
269
+ setEvents((prev) => {
270
+ const next = [...prev, { time: formatTime(), message }];
271
+ return next.length > MAX_EVENTS ? next.slice(-MAX_EVENTS) : next;
272
+ });
273
+ }, []);
274
+ useInput(
275
+ (input, key) => {
276
+ if (key.return) {
277
+ const trimmed = tokenInput.trim();
278
+ if (trimmed.length > 0) {
279
+ setActiveToken(trimmed);
280
+ setPhase("starting");
281
+ setSetupStep("active");
282
+ }
283
+ return;
284
+ }
285
+ if (key.backspace || key.delete) {
286
+ setTokenInput((prev) => prev.slice(0, -1));
287
+ return;
288
+ }
289
+ if (input && !key.ctrl && !key.meta) {
290
+ setTokenInput((prev) => prev + input.replace(/[\n\r\s]/g, ""));
291
+ }
292
+ },
293
+ { isActive: phase === "prompting" }
294
+ );
295
+ const restartJupyter = useCallback(() => {
296
+ if (jupyterProc.current) {
297
+ jupyterProc.current.kill("SIGTERM");
298
+ jupyterProc.current = null;
299
+ }
300
+ setJupyterRunning(false);
301
+ pushEvent("Restarting notebook server...");
302
+ const proc = startJupyter(port2);
303
+ jupyterProc.current = proc;
304
+ didWeSpawnJupyter.current = true;
305
+ let stderrBuf = "";
306
+ proc.stderr?.on("data", (data) => {
307
+ stderrBuf += data.toString();
308
+ const lines = stderrBuf.split("\n");
309
+ stderrBuf = lines.pop() ?? "";
310
+ for (const line of lines) {
311
+ const msg = parseJupyterLine(line);
312
+ if (msg) pushEvent(msg);
313
+ }
314
+ });
315
+ proc.on("error", () => {
316
+ setJupyterRunning(false);
317
+ pushEvent("Notebook server failed to start");
318
+ });
319
+ proc.on("exit", () => {
320
+ if (!cleanedUp.current) {
321
+ setJupyterRunning(false);
322
+ pushEvent("Notebook server stopped");
323
+ }
324
+ });
325
+ let attempts = 0;
326
+ const poll = setInterval(async () => {
327
+ attempts++;
328
+ const healthy = await checkJupyterHealth(port2);
329
+ if (healthy) {
330
+ clearInterval(poll);
331
+ setJupyterRunning(true);
332
+ pushEvent("Notebook server started");
333
+ } else if (attempts >= 20) {
334
+ clearInterval(poll);
335
+ pushEvent("Notebook server not responding");
336
+ }
337
+ }, 1e3);
338
+ }, [port2, pushEvent]);
339
+ useInput(
340
+ (_input, key) => {
341
+ if (_input === "r" || _input === "R") {
342
+ if (!key.ctrl && !key.meta) {
343
+ restartJupyter();
344
+ }
345
+ }
346
+ },
347
+ { isActive: phase === "ready" && !jupyterRunning }
348
+ );
349
+ const resetToPrompt = useCallback(() => {
350
+ setError(null);
351
+ setPhase("prompting");
352
+ setSetupStep("pending");
353
+ setJupyterStep("pending");
354
+ setTunnelStep("pending");
355
+ setTokenInput("");
356
+ setActiveToken(null);
357
+ setEvents([]);
358
+ setStartedAt(null);
359
+ setJupyterRunning(false);
360
+ setTunnelConnected(false);
361
+ }, []);
362
+ useInput(
363
+ (_input, key) => {
364
+ if (key.return) {
365
+ resetToPrompt();
366
+ }
367
+ },
368
+ { isActive: error !== null }
369
+ );
92
370
  useEffect(() => {
93
- let killed = false;
371
+ if (phase !== "starting" || !activeToken) return;
372
+ let cancelled = false;
94
373
  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], {
374
+ try {
375
+ await Promise.all([ensureCloudflared(), ensureRipgrep()]);
376
+ } catch {
377
+ if (cancelled) return;
378
+ setSetupStep("error");
379
+ setError({
380
+ message: "Could not set up required tools.",
381
+ hint: "Check your internet connection and try again."
382
+ });
383
+ return;
384
+ }
385
+ if (cancelled) return;
386
+ setSetupStep("done");
387
+ setJupyterStep("active");
388
+ const alreadyHealthy = await checkJupyterHealth(port2);
389
+ if (cancelled) return;
390
+ if (alreadyHealthy) {
391
+ setJupyterStep("done");
392
+ setJupyterRunning(true);
393
+ didWeSpawnJupyter.current = false;
394
+ pushEvent("Existing notebook server detected");
395
+ } else {
396
+ const pythonCmd = findPython();
397
+ if (!pythonCmd) {
398
+ setJupyterStep("error");
399
+ setError({
400
+ message: "Python 3 is not installed.",
401
+ hint: "Install Python 3.11+ from https://python.org and try again."
402
+ });
403
+ return;
404
+ }
405
+ if (!venvExists()) {
406
+ pushEvent("Creating Sharpe environment...");
407
+ try {
408
+ createVenv(pythonCmd);
409
+ } catch {
410
+ if (cancelled) return;
411
+ setJupyterStep("error");
412
+ setError({
413
+ message: "Could not create the Sharpe environment.",
414
+ hint: "Make sure Python 3.11+ is installed and try again."
415
+ });
416
+ return;
417
+ }
418
+ }
419
+ if (cancelled) return;
420
+ pushEvent("Installing Jupyter...");
421
+ const installResult = await new Promise((resolve) => {
422
+ const proc2 = installPackages(["jupyter", "jupyter-resource-usage", "sharpe-log-handler"]);
423
+ proc2.on("error", (err) => resolve(err.message));
424
+ proc2.on("exit", (code) => resolve(code === 0 ? null : "install-failed"));
425
+ });
426
+ if (cancelled) return;
427
+ if (installResult) {
428
+ setJupyterStep("error");
429
+ setError({
430
+ message: "Could not install Jupyter.",
431
+ hint: "Check your internet connection and try again."
432
+ });
433
+ return;
434
+ }
435
+ pushEvent("Jupyter installed");
436
+ const proc = startJupyter(port2);
437
+ jupyterProc.current = proc;
438
+ didWeSpawnJupyter.current = true;
439
+ const spawnError = await new Promise((resolve) => {
440
+ proc.on("error", (err) => resolve(err.message));
441
+ let stderrBuf = "";
442
+ proc.stderr?.on("data", (data) => {
443
+ stderrBuf += data.toString();
444
+ const lines = stderrBuf.split("\n");
445
+ stderrBuf = lines.pop() ?? "";
446
+ for (const line of lines) {
447
+ if (line.includes("Address already in use")) {
448
+ resolve("port-conflict");
449
+ return;
450
+ }
451
+ const msg = parseJupyterLine(line);
452
+ if (msg) pushEvent(msg);
453
+ }
454
+ });
455
+ let attempts = 0;
456
+ const poll = setInterval(async () => {
457
+ if (cancelled) {
458
+ clearInterval(poll);
459
+ resolve("cancelled");
460
+ return;
461
+ }
462
+ attempts++;
463
+ const healthy = await checkJupyterHealth(port2);
464
+ if (healthy) {
465
+ clearInterval(poll);
466
+ resolve(null);
467
+ } else if (attempts >= 20) {
468
+ clearInterval(poll);
469
+ resolve("timeout");
470
+ }
471
+ }, 1e3);
472
+ });
473
+ if (cancelled) return;
474
+ if (spawnError === "port-conflict") {
475
+ setJupyterStep("error");
476
+ setError({
477
+ message: "Could not start the notebook server.",
478
+ hint: `Port ${port2} is already in use.
479
+ Close whatever is using it and try again, or use a different port:
480
+
481
+ npx @sharpe-jupyter/connect CODE --port ${port2 + 1}`
482
+ });
483
+ return;
484
+ }
485
+ if (spawnError === "timeout") {
486
+ setJupyterStep("error");
487
+ setError({
488
+ message: "The notebook server isn't responding.",
489
+ hint: `Try restarting or check if port ${port2} is available.`
490
+ });
491
+ return;
492
+ }
493
+ if (spawnError && spawnError !== "cancelled") {
494
+ setJupyterStep("error");
495
+ setError({
496
+ message: "Could not start the notebook server.",
497
+ hint: "Something went wrong starting Jupyter. Try running again."
498
+ });
499
+ return;
500
+ }
501
+ setJupyterRunning(true);
502
+ pushEvent("Notebook server started");
503
+ }
504
+ if (cancelled) return;
505
+ setJupyterStep("done");
506
+ setTunnelStep("active");
507
+ const binPath = getCloudflaredPath();
508
+ const tunnelChild = spawn2(binPath, ["tunnel", "run", "--token", activeToken], {
103
509
  stdio: ["ignore", "pipe", "pipe"]
104
510
  });
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
- }
511
+ tunnelProc.current = tunnelChild;
512
+ const tunnelResult = await new Promise((resolve) => {
513
+ let connectionCount = 0;
514
+ let resolved = false;
515
+ const timeout = setTimeout(() => {
516
+ if (!resolved) {
517
+ resolved = true;
518
+ resolve("timeout");
519
+ }
520
+ }, 3e4);
521
+ const handleData = (data) => {
522
+ const text = data.toString();
523
+ if (text.includes("Registered tunnel connection")) {
524
+ connectionCount++;
525
+ if (connectionCount === 1 && !resolved) {
526
+ resolved = true;
527
+ clearTimeout(timeout);
528
+ resolve(null);
529
+ }
530
+ }
531
+ };
532
+ tunnelChild.stderr?.on("data", handleData);
533
+ tunnelChild.stdout?.on("data", handleData);
534
+ tunnelChild.on("error", (err) => {
535
+ if (!resolved) {
536
+ resolved = true;
537
+ clearTimeout(timeout);
538
+ resolve(err.message);
539
+ }
540
+ });
541
+ tunnelChild.on("exit", (code) => {
542
+ if (!resolved) {
543
+ resolved = true;
544
+ clearTimeout(timeout);
545
+ resolve(code !== 0 ? "exit-nonzero" : null);
546
+ }
547
+ });
110
548
  });
111
- child.stdout?.on("data", (data) => {
549
+ if (cancelled) return;
550
+ if (tunnelResult === "timeout") {
551
+ setTunnelStep("error");
552
+ setError({
553
+ message: "Could not connect to Sharpe.",
554
+ hint: "Check your internet connection and try again."
555
+ });
556
+ return;
557
+ }
558
+ if (tunnelResult === "exit-nonzero") {
559
+ setTunnelStep("error");
560
+ setError({
561
+ message: "Could not connect to Sharpe.",
562
+ hint: "Make sure your connection code is correct.\nGet a new code from the Sharpe dashboard."
563
+ });
564
+ return;
565
+ }
566
+ if (tunnelResult) {
567
+ setTunnelStep("error");
568
+ setError({ message: "Could not connect to Sharpe.", hint: tunnelResult });
569
+ return;
570
+ }
571
+ setTunnelStep("done");
572
+ setTunnelConnected(true);
573
+ pushEvent("Connected to Sharpe");
574
+ setPhase("ready");
575
+ setStartedAt(Date.now());
576
+ if (jupyterProc.current) {
577
+ jupyterProc.current.on("exit", () => {
578
+ if (!cleanedUp.current) {
579
+ setJupyterRunning(false);
580
+ pushEvent("Notebook server stopped");
581
+ }
582
+ });
583
+ }
584
+ let wasDisconnected = false;
585
+ const monitorTunnel = (data) => {
112
586
  const text = data.toString();
113
- if (text.includes("Registered tunnel connection")) {
114
- setPhase({ step: "connected", warning });
587
+ if (/Retrying|connection.*failed|ERR/i.test(text) && !wasDisconnected) {
588
+ wasDisconnected = true;
589
+ setTunnelConnected(false);
590
+ pushEvent("Connection interrupted, reconnecting...");
115
591
  }
116
- });
117
- child.on("error", (err) => {
118
- if (!killed) {
119
- setPhase({ step: "error", message: err.message });
120
- exit();
592
+ if (text.includes("Registered tunnel connection") && wasDisconnected) {
593
+ wasDisconnected = false;
594
+ setTunnelConnected(true);
595
+ pushEvent("Reconnected to Sharpe");
121
596
  }
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();
597
+ };
598
+ tunnelChild.stderr?.on("data", monitorTunnel);
599
+ tunnelChild.stdout?.on("data", monitorTunnel);
600
+ tunnelChild.on("exit", () => {
601
+ if (!cleanedUp.current) {
602
+ setTunnelConnected(false);
603
+ pushEvent("Tunnel disconnected");
129
604
  }
130
605
  });
131
- const cleanup = () => {
132
- killed = true;
133
- child.kill("SIGTERM");
134
- };
135
- process.on("SIGINT", cleanup);
136
- process.on("SIGTERM", cleanup);
137
606
  }
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: [
607
+ run();
608
+ const cleanup = () => {
609
+ cancelled = true;
610
+ cleanedUp.current = true;
611
+ setPhase("shutting-down");
612
+ if (didWeSpawnJupyter.current && jupyterProc.current) {
613
+ jupyterProc.current.kill("SIGTERM");
614
+ }
615
+ if (tunnelProc.current) {
616
+ tunnelProc.current.kill("SIGTERM");
617
+ }
618
+ setTimeout(() => {
619
+ exit();
620
+ process.exit(0);
621
+ }, 1500);
622
+ };
623
+ process.on("SIGINT", cleanup);
624
+ process.on("SIGTERM", cleanup);
625
+ return () => {
626
+ cancelled = true;
627
+ process.removeListener("SIGINT", cleanup);
628
+ process.removeListener("SIGTERM", cleanup);
629
+ };
630
+ }, [phase, activeToken, port2, exit, pushEvent]);
631
+ const overallStatus = !jupyterRunning && !tunnelConnected ? "offline" : !tunnelConnected ? "reconnecting" : !jupyterRunning ? "notebook-stopped" : "connected";
632
+ if (phase === "shutting-down") {
633
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
634
+ /* @__PURE__ */ jsx(Header, {}),
635
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Shutting down..." }) })
636
+ ] });
637
+ }
638
+ if (phase === "prompting") {
639
+ const display = tokenInput.length > 50 ? `${tokenInput.slice(0, 20)}...${tokenInput.slice(-10)}` : tokenInput;
640
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
641
+ /* @__PURE__ */ jsx(Header, {}),
642
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { children: "Paste your connection code from the Sharpe dashboard:" }) }),
162
643
  /* @__PURE__ */ jsxs(Box, { children: [
163
- /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
164
- /* @__PURE__ */ jsx(Text, { children: " Establishing tunnel connection..." })
644
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "> " }),
645
+ tokenInput.length > 0 ? /* @__PURE__ */ jsxs(Text, { children: [
646
+ display,
647
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
648
+ " (",
649
+ tokenInput.length,
650
+ " chars)"
651
+ ] })
652
+ ] }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "paste here and press Enter" })
653
+ ] })
654
+ ] });
655
+ }
656
+ if (error) {
657
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
658
+ /* @__PURE__ */ jsx(Header, {}),
659
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
660
+ setupStep === "done" && /* @__PURE__ */ jsx(Step, { status: "done", label: "Setting up" }),
661
+ setupStep === "error" && /* @__PURE__ */ jsx(Step, { status: "error", label: "Setting up" }),
662
+ jupyterStep === "done" && /* @__PURE__ */ jsx(
663
+ Step,
664
+ {
665
+ status: "done",
666
+ label: "Notebook server running",
667
+ detail: `http://localhost:${port2}`
668
+ }
669
+ ),
670
+ jupyterStep === "error" && /* @__PURE__ */ jsx(Step, { status: "error", label: "Could not start notebook server" }),
671
+ tunnelStep === "error" && /* @__PURE__ */ jsx(Step, { status: "error", label: "Connecting to Sharpe" })
672
+ ] }),
673
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
674
+ /* @__PURE__ */ jsx(Text, { color: "red", children: error.message }),
675
+ error.hint && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: error.hint }) })
165
676
  ] }),
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 })
677
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press Enter to try again or Ctrl+C to quit." }) })
678
+ ] });
679
+ }
680
+ if (phase === "starting") {
681
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
682
+ /* @__PURE__ */ jsx(Header, {}),
683
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
684
+ /* @__PURE__ */ jsx(Step, { status: setupStep, label: "Setting up" }),
685
+ /* @__PURE__ */ jsx(
686
+ Step,
687
+ {
688
+ status: jupyterStep,
689
+ label: "Notebook server running",
690
+ detail: jupyterStep === "done" ? `http://localhost:${port2}` : void 0
691
+ }
692
+ ),
693
+ /* @__PURE__ */ jsx(Step, { status: tunnelStep, label: "Connecting to Sharpe" })
169
694
  ] })
170
- ] }),
171
- phase.step === "connected" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
695
+ ] });
696
+ }
697
+ const footerText = !jupyterRunning ? "Press R to restart notebook, or Ctrl+C to quit." : "Press Ctrl+C when you're done.";
698
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
699
+ /* @__PURE__ */ jsx(Header, {}),
700
+ /* @__PURE__ */ jsx(Box, { justifyContent: "space-between", marginTop: 1, children: /* @__PURE__ */ jsx(StatusBadge, { status: overallStatus }) }),
701
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
172
702
  /* @__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 })
703
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Notebook " }),
704
+ jupyterRunning ? /* @__PURE__ */ jsx(Text, { children: `http://localhost:${port2}` }) : /* @__PURE__ */ jsx(Text, { color: "red", children: "Not running" })
179
705
  ] }),
180
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press Ctrl+C to disconnect" })
706
+ /* @__PURE__ */ jsxs(Box, { children: [
707
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Uptime " }),
708
+ /* @__PURE__ */ jsx(Text, { children: uptime })
709
+ ] })
181
710
  ] }),
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 })
711
+ events.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
712
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }),
713
+ events.map((evt, i) => /* @__PURE__ */ jsxs(Box, { children: [
714
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
715
+ evt.time,
716
+ " "
717
+ ] }),
718
+ /* @__PURE__ */ jsx(Text, { children: evt.message })
719
+ ] }, i))
720
+ ] }),
721
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: footerText }) })
722
+ ] });
723
+ }
724
+ function Header() {
725
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
726
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: LOGO }),
727
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Sharpe Connect" })
728
+ ] });
729
+ }
730
+ function Step({ status, label, detail }) {
731
+ const icon = status === "done" ? /* @__PURE__ */ jsx(Text, { color: "green", children: "v" }) : status === "active" ? /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }) : status === "error" ? /* @__PURE__ */ jsx(Text, { color: "red", children: "x" }) : status === "skipped" ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "-" }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "." });
732
+ return /* @__PURE__ */ jsxs(Box, { children: [
733
+ icon,
734
+ /* @__PURE__ */ jsxs(Text, { children: [
735
+ " ",
736
+ label
737
+ ] }),
738
+ detail && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
739
+ " ",
740
+ detail
188
741
  ] })
189
742
  ] });
190
743
  }
744
+ function StatusBadge({ status }) {
745
+ switch (status) {
746
+ case "connected":
747
+ return /* @__PURE__ */ jsxs(Box, { children: [
748
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF " }),
749
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "Connected" })
750
+ ] });
751
+ case "reconnecting":
752
+ return /* @__PURE__ */ jsxs(Box, { children: [
753
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CF " }),
754
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Reconnecting..." })
755
+ ] });
756
+ case "notebook-stopped":
757
+ return /* @__PURE__ */ jsxs(Box, { children: [
758
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CF " }),
759
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Notebook stopped" })
760
+ ] });
761
+ case "offline":
762
+ return /* @__PURE__ */ jsxs(Box, { children: [
763
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "\u25CF " }),
764
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "Offline" })
765
+ ] });
766
+ }
767
+ }
768
+
769
+ // src/index.tsx
770
+ import { jsx as jsx2 } from "react/jsx-runtime";
191
771
  function parseArgs(argv) {
192
772
  const args = argv.slice(2);
193
- let token2;
194
- let port2 = 8e3;
773
+ let connectionCode2 = null;
774
+ let port2 = 8888;
195
775
  for (let i = 0; i < args.length; i++) {
196
776
  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") {
777
+ if (arg === "--help" || arg === "-h") {
202
778
  console.log(`
203
- Usage: sharpe-connect --token <TOKEN> [--port <PORT>]
779
+ Usage: sharpe-connect [connection-code] [options]
204
780
 
205
781
  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
782
+ --port, -p Notebook port (default: 8888)
783
+ --help, -h Show help
209
784
  `);
210
785
  process.exit(0);
211
786
  }
787
+ if ((arg === "--port" || arg === "-p") && args[i + 1]) {
788
+ port2 = parseInt(args[++i], 10);
789
+ continue;
790
+ }
791
+ if (!arg.startsWith("-") && connectionCode2 === null) {
792
+ connectionCode2 = arg;
793
+ }
212
794
  }
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 };
795
+ return { connectionCode: connectionCode2, port: port2 };
218
796
  }
219
- var { token, port } = parseArgs(process.argv);
220
- render(/* @__PURE__ */ jsx(App, { token, port }));
797
+ var { connectionCode, port } = parseArgs(process.argv);
798
+ render(/* @__PURE__ */ jsx2(App, { connectionCode, port }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sharpe-jupyter/connect",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Connect a local JupyterHub to Sharpe via Cloudflare Tunnel",
5
5
  "type": "module",
6
6
  "bin": {