diff --git a/packages/cli/postinstall.js b/packages/cli/installCloudflared.js similarity index 70% rename from packages/cli/postinstall.js rename to packages/cli/installCloudflared.js index 774326cc8..721fad6b2 100644 --- a/packages/cli/postinstall.js +++ b/packages/cli/installCloudflared.js @@ -4,10 +4,12 @@ */ const { execSync } = require('child_process'); const fs = require('fs'); +const os = require('os'); const https = require('https'); const path = require('path'); const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/'; +const CLOUDFLARED_VERSION = '2026.3.0'; const LINUX_URL = { arm64: 'cloudflared-linux-arm64', @@ -33,11 +35,61 @@ function resolveBase(version) { return `${RELEASE_BASE}download/${version}/`; } +function getGitBookDataDirectory() { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'gitbook'); + } + + if (process.platform === 'win32') { + return path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'gitbook', + ); + } + + return path.join( + process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), + 'gitbook', + ); +} + +function getCloudflaredBinaryPath() { + return path.join( + getGitBookDataDirectory(), + 'bin', + process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared', + ); +} + +async function hasCloudflared(binaryPath) { + const mode = process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK; + + try { + await fs.promises.access(binaryPath, mode); + return true; + } catch { + return false; + } +} + +async function ensureCloudflaredInstalled(onInstall = () => {}, onComplete = () => {}) { + const binaryPath = getCloudflaredBinaryPath(); + + if (await hasCloudflared(binaryPath)) { + return binaryPath; + } + + onInstall(); + await installCloudflared(binaryPath, CLOUDFLARED_VERSION); + onComplete(); + return binaryPath; +} + /** * Install cloudflared to the given path. - * @param to The path to the binary to install. - * @param version The version of cloudflared to install. - * @returns The path to the binary that was installed. + * @param {string} to The path to the binary to install. + * @param {string} version The version of cloudflared to install. + * @returns {Promise} The path to the binary that was installed. */ async function installCloudflared(to, version = 'latest') { if (process.platform === 'linux') { @@ -136,8 +188,4 @@ function download(url, to, redirect = 0) { }); } -/** - * Install the cloudflared binary inside the dist folder. - * Locking the version to 2023.4.1 instead of latest to avoid breaking changes. - */ -installCloudflared(path.join(__dirname, 'dist', 'cloudflared'), '2023.4.1'); +module.exports = ensureCloudflaredInstalled; diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f2a6ae2a..2ad8c3a5f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,11 +26,10 @@ }, "files": [ "dist/**", - "postinstall.js" + "installCloudflared.js" ], "scripts": { "build": "./build.sh", - "postinstall": "node postinstall.js", "typecheck": "tsc --noEmit" }, "engines": { diff --git a/packages/cli/src/cloudflared.ts b/packages/cli/src/cloudflared.ts new file mode 100644 index 000000000..2b131a72d --- /dev/null +++ b/packages/cli/src/cloudflared.ts @@ -0,0 +1,18 @@ +import * as path from 'path'; + +type EnsureCloudflaredInstalled = ( + onInstall?: () => void, + onComplete?: () => void, +) => Promise; + +export async function ensureCloudflaredInstalled( + onInstall?: () => void, + onComplete?: () => void, +): Promise { + // This file is bundled into dist/cli.js, so resolve the helper from the + // published package root at runtime rather than bundling it into the CLI. + const installer = require( + path.resolve(__dirname, '../installCloudflared.js'), + ) as EnsureCloudflaredInstalled; + return installer(onInstall, onComplete); +} diff --git a/packages/cli/src/dev.ts b/packages/cli/src/dev.ts index 4314ce8a9..aa6a09d63 100644 --- a/packages/cli/src/dev.ts +++ b/packages/cli/src/dev.ts @@ -1,10 +1,12 @@ import chokidar from 'chokidar'; import getPort from 'get-port'; import { Log, LogLevel, Miniflare, MiniflareOptions } from 'miniflare'; +import os from 'os'; import ora from 'ora'; import * as path from 'path'; import { buildScriptFromManifest } from './build'; +import { ensureCloudflaredInstalled } from './cloudflared'; import { resolveFile } from './manifest'; import { getAPIClient } from './remote'; import { createDevTunnel } from './tunnel'; @@ -17,6 +19,7 @@ export function getMiniflareOptions(scriptPath: string): MiniflareOptions { scriptPath, modules: true, modulesRoot: path.dirname(scriptPath), + cf: path.join(os.tmpdir(), '.gitbook', 'miniflare', 'cf.json'), compatibilityDate: '2025-05-25', compatibilityFlags: ['nodejs_compat'], }; @@ -49,13 +52,18 @@ export async function startIntegrationsDevServer( const port = await getPort(); spinner.succeed(`Local port ${port} allocated`); + const cloudflaredPath = await ensureCloudflaredInstalled( + () => spinner.start('Installing cloudflared...'), + () => spinner.succeed(`cloudflared ready`), + ); + /** * Create a tunnel to allow the dev server to receive integration events * from the GitBook platform */ spinner.start('Creating HTTPS tunnel...'); - const tunnelUrl = await createDevTunnel(port); - spinner.succeed(`Tunnel created ${tunnelUrl}`); + const tunnel = await createDevTunnel(port, cloudflaredPath); + spinner.succeed(`Tunnel created ${tunnel.tunnelUrl}`); /** * Start the miniflare dev server. It will automatically reload the script @@ -73,17 +81,19 @@ export async function startIntegrationsDevServer( }; const mf = new Miniflare(miniflareOptions); await mf.ready; - spinner.succeed(`Dev server started`); + spinner.succeed(`Dev server started 🔥`); /** * Add the tunnel to the integration for the dev space events in the GitBook platform */ spinner.start(`Enabling development mode for ${manifest.name}...`); const api = await getAPIClient(true); - await api.integrations.setIntegrationDevelopmentMode(manifest.name, { tunnelUrl, all }); + await api.integrations.setIntegrationDevelopmentMode(manifest.name, { + tunnelUrl: tunnel.tunnelUrl, + all, + }); spinner.succeed(`Development mode enabled`); - spinner.succeed(`Dev server 🔥`); spinner.info( `Integration events${all ? ` originating from the organization ${manifest.organization}` : ''} will be dispatched to your locally running version of the integration.`, ); diff --git a/packages/cli/src/tunnel.ts b/packages/cli/src/tunnel.ts index 516d474c2..74b3e41f3 100644 --- a/packages/cli/src/tunnel.ts +++ b/packages/cli/src/tunnel.ts @@ -1,20 +1,43 @@ import { spawn } from 'child_process'; -import * as path from 'path'; + +export interface DevTunnel { + tunnelUrl: string; + close(): void; +} /** * Create a tunnel using `cloudflared` mapped to `localhost:{port}` */ -export function createDevTunnel(port: number): Promise { +export function createDevTunnel(port: number, cloudflaredPath: string): Promise { return new Promise((resolve, reject) => { let tunnelUrl: string; let connectionsCount = 0; - const cloudflared = spawn(path.join(__dirname, 'cloudflared'), [ + let resolved = false; + const cloudflared = spawn(cloudflaredPath, [ 'tunnel', '--no-autoupdate', '--url', `http://localhost:${port}`, ]); + const close = () => { + if (cloudflared.exitCode === null && !cloudflared.killed) { + cloudflared.kill('SIGTERM'); + } + }; + + const resolveWhenReady = () => { + if (resolved || !tunnelUrl || connectionsCount < 1) { + return; + } + + resolved = true; + resolve({ + tunnelUrl, + close, + }); + }; + cloudflared.stderr.on('data', (data) => { const output = data.toString(); @@ -31,14 +54,18 @@ export function createDevTunnel(port: number): Promise { connectionsCount++; } - if (connectionsCount >= 1) { - resolve(tunnelUrl); - } + resolveWhenReady(); }); cloudflared.on('close', (code) => { - if (code !== 0) { - throw new Error(`cloudflared exited with code ${code}`); + if (!resolved && code !== 0) { + reject(new Error(`cloudflared exited with code ${code}`)); + } + }); + + cloudflared.on('error', (error) => { + if (!resolved) { + reject(error); } }); });