Repository: stateful/vscode-runme
Pull request #355 (2023-03-28 16:31:01)

Use Task API for cloning project instead of timing out in Uri Handler

christian-bromann: @mxsdev let me know if you need any support getting this over the line.
mxsdev: @christian-bromann 

Sure, I think I just need to fix tests. Not a super high priority which is why it's been sitting in draft so long.

Patch:
diff --git a/.github/scripts/downloadAndExtract.js b/.github/scripts/downloadAndExtract.js
index c51df6f7..28a94a83 100644
--- a/.github/scripts/downloadAndExtract.js
+++ b/.github/scripts/downloadAndExtract.js
@@ -8,118 +8,130 @@ import { parseArgs, promisify } from 'node:util'
 import tar from 'tar-fs'
 import unzipper from 'unzipper'
 
+import { getLastStableRelease } from './getLastStableRelease.js'
+
 const streamPipeline = promisify(pipeline)
 const VALID_FORMATS = ['zip', 'tar']
 const SCHEMA = {
-    downloadUrl: {
-        type: 'string',
-        short: 'd',
-        description: 'Binary file Download URL'
-    },
-    type: {
-        type: 'string',
-        short: 'f',
-        default: 'tar',
-        description: 'The compressed file format (zip, tar)'
-    },
-    binaryDestination: {
-        type: 'string',
-        short: 'b',
-        default: 'bin',
-        description: 'The destination binary folder (defaults to bin)'
-    },
-    binaryName: {
-        type: 'string',
-        short: 'n',
-        default: 'runme',
-        description: 'The distributed binary name (defaults to runme)'
-    }
+  downloadUrl: {
+    type: 'string',
+    short: 'd',
+    default: process.env.DOWNLOAD_URL,
+    description: 'Binary file Download URL',
+  },
+  type: {
+    type: 'string',
+    short: 'f',
+    default: process.env.TYPE,
+    description: 'The compressed file format (zip, tar)'
+  },
+  binaryDestination: {
+    type: 'string',
+    short: 'b',
+    default: 'bin',
+    description: 'The destination binary folder (defaults to bin)'
+  },
+  binaryName: {
+    type: 'string',
+    short: 'n',
+    default: 'runme',
+    description: 'The distributed binary name (defaults to runme)'
+  }
 }
 
 const displayToolHelp = () => {
-    console.log('Tool usage')
-    for (const [, { short, description }] of Object.entries(SCHEMA)) {
-        console.log(`-${short}:${description}`)
-    }
+  console.log('Tool usage')
+  for (const [, { short, description }] of Object.entries(SCHEMA)) {
+    console.log(`-${short}:${description}`)
+  }
 }
 
 const cleanCliDownload = async (cliDownloadFolder) => {
-    if (
-        !await fsp.stat(cliDownloadFolder)
-            .then((val) => val.isDirectory())
-            .catch(() => false)
-    ) {
-        console.info("No CLI Download folder found, nothing to do!")
-        return
-    }
-
-    console.info("🗑️ Cleaning CLI Download folder...")
-
-    await fsp.rm(cliDownloadFolder, { recursive: true, force: true })
+  if (
+    !await fsp.stat(cliDownloadFolder)
+      .then((val) => val.isDirectory())
+      .catch(() => false)
+  ) {
+    console.info("No CLI Download folder found, nothing to do!")
+    return
+  }
 
-    console.info("✅ CLI Download folder cleared!")
+  console.info("🗑️ Cleaning CLI Download folder...")
+  await fsp.rm(cliDownloadFolder, { recursive: true, force: true })
+  console.info("✅ CLI Download folder cleared!")
 }
 
 const downloadBinary = async (downloadUrl, type, binaryDestination, binaryName) => {
-    if (!VALID_FORMATS.includes(type)) {
-        throw new Error(`Invalid type ${type}, it must be zip or tar file`)
-    }
+  if (!type) {
+    type = path.extname(downloadUrl) === '.zip'
+      ? 'zip'
+      : path.extname(downloadUrl) === '.gz'
+        ? 'tar'
+        : 'unknown'
+  }
 
-    if (!downloadUrl) {
-        throw new Error('Missing a download URL')
-    }
+  if (!VALID_FORMATS.includes(type)) {
+    throw new Error(`Invalid type ${type}, it must be zip or tar file`)
+  }
 
-    if (!binaryName) {
-        throw new Error('Missing binary name')
-    }
+  if (!downloadUrl) {
+    throw new Error('Missing a download URL')
+  }
 
-    const cliFilePath = path.resolve(binaryDestination)
-    await cleanCliDownload(cliFilePath)
-    await fsp.mkdir(cliFilePath, { recursive: true })
-    console.log(`Checking binary destination path: ${cliFilePath}`)
-    console.log(`Downloading binary ${downloadUrl}`)
-    const res = await fetch(downloadUrl)
-    if (type === 'tar') {
-        console.log('Extracting tar file')
-        await streamPipeline(res.body, zlib.createGunzip(), tar.extract(cliFilePath, {
-            ignore: (name) => path.basename(name) !== binaryName
-        }))
-    } else {
-        console.log('Extracting zip file')
-        const stream = Readable.from(res.body).pipe(unzipper.Parse())
+  if (!binaryName) {
+    throw new Error('Missing binary name')
+  }
 
-        stream.on('entry', (entry) => {
-            const fileName = entry.path
-            const type = entry.type
-            if (type === 'File' && path.extname(fileName) === '.exe') {
-                const execStream = entry.pipe(fs.createWriteStream(path.join(cliFilePath, binaryName)))
-                // workaround for github actions, which will throw an error if you
-                // try to write directly to an `exe` file
-                execStream.on('close', async () => {
-                    await fsp.rename(
-                        path.join(cliFilePath, binaryName),
-                        path.join(cliFilePath, `${binaryName}.exe`)
-                    )
-                })
-            }
-        })
+  const cliFilePath = path.resolve(binaryDestination)
+  await cleanCliDownload(cliFilePath)
+  await fsp.mkdir(cliFilePath, { recursive: true })
+  console.log(`Checking binary destination path: ${cliFilePath}`)
+  console.log(`Downloading binary ${downloadUrl}`)
+  const res = await fetch(downloadUrl)
+  if (type === 'tar') {
+    console.log('Extracting tar file')
+    await streamPipeline(res.body, zlib.createGunzip(), tar.extract(cliFilePath, {
+      ignore: (name) => path.basename(name) !== binaryName
+    }))
+  } else {
+    console.log('Extracting zip file')
+    const stream = Readable.from(res.body).pipe(unzipper.Parse())
 
-        // wait for stream to close
-        await new Promise((res, rej) => {
-            stream.on('close', res)
-            stream.on('error', (err) => rej(err))
+    stream.on('entry', (entry) => {
+      const fileName = entry.path
+      const type = entry.type
+      if (type === 'File' && path.extname(fileName) === '.exe') {
+        const execStream = entry.pipe(fs.createWriteStream(path.join(cliFilePath, binaryName)))
+        // workaround for github actions, which will throw an error if you
+        // try to write directly to an `exe` file
+        execStream.on('close', async () => {
+          await fsp.rename(
+            path.join(cliFilePath, binaryName),
+            path.join(cliFilePath, `${binaryName}.exe`)
+          )
         })
-    }
-    console.log(`✅ Successfully downloaded and unpacked CLI executable file to ${cliFilePath}`)
+      }
+    })
+
+    // wait for stream to close
+    await new Promise((res, rej) => {
+      stream.on('close', res)
+      stream.on('error', (err) => rej(err))
+    })
+  }
+  console.log(`✅ Successfully downloaded and unpacked CLI executable file to ${cliFilePath}`)
 }
 
-const { values: { downloadUrl, type, binaryDestination, binaryName } } = parseArgs({
-    options: SCHEMA
+let { values: { downloadUrl, type, binaryDestination, binaryName } } = parseArgs({
+  options: SCHEMA
 })
 
 try {
-    await downloadBinary(downloadUrl, type, binaryDestination, binaryName)
+  if (!downloadUrl) {
+    downloadUrl = await getLastStableRelease()
+  }
+  await downloadBinary(downloadUrl, type, binaryDestination, binaryName)
 } catch (error) {
-    displayToolHelp()
-    throw error
+  displayToolHelp()
+  throw error
 }
diff --git a/.github/scripts/getLastStableRelease.js b/.github/scripts/getLastStableRelease.js
index 96656889..1d451c99 100644
--- a/.github/scripts/getLastStableRelease.js
+++ b/.github/scripts/getLastStableRelease.js
@@ -1,7 +1,7 @@
 import os from 'node:os'
 
 
-const getLastStableRelease = async () => {
+export const getLastStableRelease = async () => {
   const branch = process.env.GITHUB_REF_NAME ?? 'main'
   const suffix = branch === 'main' ? '/latest' : ''
 
@@ -18,15 +18,18 @@ const getLastStableRelease = async () => {
   })
 
   const { tag_name } = json
-  const arch = os.arch()
+  const arch = os.arch() === 'x64' ? 'x86_64' : os.arch() === 'ia32' ? 'arm64' : os.arch()
   const ext = os.platform() === 'win32' ? 'zip' : 'tar.gz'
-  const binary = `${os.platform().replace('win32', 'windows')}_${arch === 'x64' ? 'x86_64' : arch}.${ext}`
+  const binary = `${os.platform().replace('win32', 'windows')}_${arch}.${ext}`
   const downloadUrl = `https://download.stateful.com/runme/${tag_name.replace('v', '')}/runme_${binary}`
+  return downloadUrl
+}
+
+/**
+ * execute if called directly
+ */
+if (process.argv[1] && process.argv[1].endsWith('getLastStableRelease.js')) {
+  const downloadUrl = await getLastStableRelease()
   // This console log is important since it's being exported to a ENV VAR
   console.log(downloadUrl)
 }
-
-
-await getLastStableRelease()
-
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e781a31a..a5a0d2ad 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -86,7 +86,7 @@ This should also download the Runme CLI WASM file. If an error happened follow t
 ```sh { interactive=false }
 export GITHUB_REF_NAME=$(git branch --show-current)
 GITHUB_TOKEN=$(gh auth token) npm run download:wasm
-DOWNLOAD_URL=$(node .github/scripts/getLastStableRelease) TYPE=tar npm run prepare-binary
+npm run prepare-binary -- -f tar
 ```
 
 ### Build Project
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index 9e7247b7..7348f62b 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -92,7 +92,7 @@ export const tasks = {
   registerTaskProvider: vi.fn(),
   onDidEndTaskProcess: vi.fn(),
   onDidStartTaskProcess: vi.fn(),
-  executeTask: vi.fn()
+  executeTask: vi.fn().mockResolvedValue(undefined)
 }
 
 export const commands = {
@@ -255,3 +255,18 @@ export class CodeLens {
     public command?: Command
   ) { }
 }
+
+export enum TaskScope {
+  /**
+   * The task is a global task. Global tasks are currently not supported.
+   */
+  Global = 1,
+
+  /**
+   * The task is a workspace task
+   */
+  Workspace = 2
+}
+
+export const ShellExecution = vi.fn()
+export const Task = vi.fn()
diff --git a/examples/branchGPT.md b/examples/branchGPT.md
index 2423a421..59d439eb 100644
--- a/examples/branchGPT.md
+++ b/examples/branchGPT.md
@@ -1,7 +1,13 @@
-# Intro to BranchGPT
+# Enter BranchGPT
 
 [![](https://badgen.net/badge/Run%20this%20/Demo/5B3ADF?icon=https://runme.dev/img/logo.svg)](https://runme.dev/api/runme?repository=https://github.com/stateful/vscode-runme.git&fileToOpen=examples/branchGPT.md)
 
+A humerous attempt to use GPT3/3.5/4 and Git Merge Commit to suggest new branch names.
+
+![BranchGPT in Action](https://media.graphassets.com/SyNFcxcHRG2PHtqCXCNx)
+
+Check out [this blog post](https://stateful.com/blog/branchgpt-ai-powered-branch-names) for more background.
+
 On Windows, install via scoop (https://scoop.sh):
 
 ```sh
@@ -25,4 +31,4 @@ Just enter some free-flow description and voilà, get some branch suggestions:
 $ runme branchGPT
 ```
 
-*Read the docs on [runme.dev](https://www.runme.dev/docs/intro) to learn how to get most out of Runme notebooks!*
+_Read the docs on [runme.dev](https://www.runme.dev/docs/intro) to learn how to get most out of Runme notebooks!_
diff --git a/package.json b/package.json
index a0cb7327..a60f6390 100644
--- a/package.json
+++ b/package.json
@@ -508,8 +508,8 @@
     "pretest:e2e": "npm run download:binary",
     "watch": "npm run build:dev -- --watch",
     "vscode:prepublish": "npm run prepare-binary",
-    "download:binary": "DOWNLOAD_URL=$(node .github/scripts/getLastStableRelease) TYPE=tar npm run prepare-binary",
-    "prepare-binary": "node .github/scripts/downloadAndExtract.js -d $DOWNLOAD_URL -f $TYPE -b bin -n runme"
+    "download:binary": "npm run prepare-binary",
+    "prepare-binary": "node .github/scripts/downloadAndExtract.js -b bin -n runme"
   },
   "devDependencies": {
     "@octokit/rest": "^19.0.8",
diff --git a/src/extension/handler/uri.ts b/src/extension/handler/uri.ts
index 93ba85c9..c2e93322 100644
--- a/src/extension/handler/uri.ts
+++ b/src/extension/handler/uri.ts
@@ -1,16 +1,18 @@
 import path from 'node:path'
 
-import { UriHandler, window, Uri, Progress, ProgressLocation, commands, workspace, ExtensionContext } from 'vscode'
+import {
+  UriHandler, window, Uri, Progress, ProgressLocation, commands, workspace,
+  ExtensionContext, Task, TaskScope, ShellExecution, tasks
+} from 'vscode'
 import got from 'got'
 import { v4 as uuidv4 } from 'uuid'
 import { TelemetryReporter } from 'vscode-telemetry'
 
 import {
-  getProjectDir, getTargetDirName, waitForProjectCheckout, getSuggestedProjectName, writeBootstrapFile,
+  getProjectDir, getTargetDirName, getSuggestedProjectName, writeBootstrapFile,
   parseParams
 } from './utils'
 
-let NEXT_TERM_ID = 0
 const REGEX_WEB_RESOURCE = /^https?:\/\//
 
 export class RunmeUriHandler implements UriHandler {
@@ -111,23 +113,49 @@ export class RunmeUriHandler implements UriHandler {
     fileToOpen: string
   ) {
     progress.report({ increment: 0, message: 'Cloning repository...' })
-    const terminal = window.createTerminal(`Runme Terminal #${NEXT_TERM_ID++}`)
-    terminal.show(true)
 
-    terminal.sendText(`git clone ${repository} "${targetDirUri.fsPath}"`)
-    const success = await new Promise<boolean>(
-      (resolve) => waitForProjectCheckout(fileToOpen, targetDirUri, repository, resolve))
+    const taskExecution = new Task(
+      { type: 'shell', name: 'Clone Repo' },
+      TaskScope.Workspace,
+      'Clone Repo',
+      'exec',
+      new ShellExecution(`git clone ${repository} "${targetDirUri.fsPath}"`)
+    )
+
+    const success = await new Promise<boolean>((resolve) => {
+      tasks.executeTask(taskExecution).then((execution) => {
+        tasks.onDidEndTaskProcess((e) => {
+          const taskId = (e.execution as any)['_id']
+          const executionId = (execution as any)['_id']
+
+          if (
+            taskId !== executionId ||
+            typeof e.exitCode === 'undefined'
+          ) {
+            return resolve(false)
+          }
+
+          /**
+           * only close terminal if execution passed and desired by user
+           */
+          return resolve(e.exitCode === 0)
+        })
+      })
+    })
 
     if (!success) {
-      return terminal.dispose()
+      window.showErrorMessage('Failed to checkout repository; see integrated terminal for more details/logs')
+      return
     }
 
+    await workspace.fs.stat(Uri.joinPath(targetDirUri, fileToOpen))
+      .then(() => writeBootstrapFile(targetDirUri, fileToOpen))
+
     progress.report({ increment: 50, message: 'Opening project...' })
     console.log(`[Runme] Attempt to open folder ${targetDirUri.fsPath}`)
     await commands.executeCommand('vscode.openFolder', targetDirUri, {
       forceNewWindow: true
     })
     progress.report({ increment: 100 })
-    terminal.dispose()
   }
 }
diff --git a/src/extension/handler/utils.ts b/src/extension/handler/utils.ts
index 160b07a7..73fab07b 100644
--- a/src/extension/handler/utils.ts
+++ b/src/extension/handler/utils.ts
@@ -5,9 +5,6 @@ import { workspace, window, Uri, ExtensionContext } from 'vscode'
 import { BOOTFILE } from '../constants'
 
 const config = workspace.getConfiguration('runme.checkout')
-const INTERVAL = 500
-const MINIMAL_TIMEOUT = 10 * 1000
-const MAX_STAT_TRIES = 50
 
 /**
  * Get the project directory from the settings object.
@@ -83,66 +80,6 @@ export async function getTargetDirName(targetDir: Uri, suggestedName: string, in
   return amendedSuggestedName
 }
 
-export async function waitForProjectCheckout(
-  fileToOpen: string,
-  targetDirUri: Uri,
-  repository: string,
-  cb: (success: boolean) => void
-) {
-  const t = setTimeout(() => {
-    clearInterval(i)
-    window.showErrorMessage(
-      `Timed out cloning repository ${repository}! ` +
-      'Please make sure you have Git set-up and permissions to access the repository ' +
-      'or use use an HTTPS url when cloning public repositories.'
-    )
-    return cb(false)
-  }, Math.max(config.get('timeout') || 0, MINIMAL_TIMEOUT))
-  const i = setInterval(async () => {
-    const dirEntries = await workspace.fs.readDirectory(targetDirUri).then((res) => res, (err) => {
-      console.error(`[Runme] Failed to fetch directory ${targetDirUri.toString()}: ${err.message}`)
-      return []
-    })
-
-    /**
-     * wait until directory has more files than only a ".git"
-     */
-    if (dirEntries.length <= 1) {
-      return
-    }
-
-    clearTimeout(t)
-    clearInterval(i)
-    console.log(`[Runme] Successfully cloned repository to ${targetDirUri.toString()}`)
-
-    const fileUri = Uri.joinPath(targetDirUri, fileToOpen)
-
-    let statTries = 0
-    while (statTries <= MAX_STAT_TRIES) {
-      try {
-        await workspace.fs.stat(fileUri)
-        break
-      } catch(e) {
-        if (statTries === MAX_STAT_TRIES) {
-          return cb(false)
-        }
-      }
-
-      await new Promise((r) => setTimeout(r, 100))
-
-      statTries++
-    }
-
-    /**
-     * write a runme file into the directory, so the extension knows it has
-     * to open the readme file
-     */
-    await writeBootstrapFile(targetDirUri, fileToOpen)
-
-    cb(true)
-  }, INTERVAL)
-}
-
 export async function writeBootstrapFile(targetDirUri: Uri, fileToOpen: string) {
   const enc = new TextEncoder()
   await workspace.fs.writeFile(


Test patch:
diff --git a/tests/extension/handler/uri.test.ts b/tests/extension/handler/uri.test.ts
index 68f85b55..a48f1165 100644
--- a/tests/extension/handler/uri.test.ts
+++ b/tests/extension/handler/uri.test.ts
@@ -1,12 +1,12 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 // @ts-expect-error mock feature
-import { commands, window, Uri, terminal, workspace } from 'vscode'
+import { commands, window, Uri, terminal, workspace, tasks } from 'vscode'
 import got from 'got'
 import { TelemetryReporter } from 'vscode-telemetry'
 
 import { RunmeUriHandler } from '../../../src/extension/handler/uri'
 import {
-  getProjectDir, waitForProjectCheckout, getSuggestedProjectName, writeBootstrapFile, parseParams, getTargetDirName
+  getProjectDir, getSuggestedProjectName, writeBootstrapFile, parseParams, getTargetDirName
 } from '../../../src/extension/handler/utils'
 
 vi.mock('vscode')
@@ -161,24 +161,35 @@ describe('RunmeUriHandler', () => {
       handler = new RunmeUriHandler({} as any)
       progress.report.mockClear()
       terminal.dispose.mockClear()
+      vi.mocked(tasks.onDidEndTaskProcess).mockReset()
     })
 
-    it('should return false if waitForProjectCheckout fails', async () => {
-      vi.mocked(waitForProjectCheckout).mockImplementation(
-        async (_, __, ___, resolve) => resolve(false))
+    it('should return false if executeTask fails', async () => {
+      vi.mocked(tasks.executeTask).mockResolvedValue({ _id: 'id' } as any)
+      vi.mocked(tasks.onDidEndTaskProcess).mockImplementation((x) => x({
+        execution: {
+          _id: 'id'
+        },
+        exitCode: 1
+      } as any))
+
       await handler['_cloneProject'](progress, { fsPath: '/bar/foo' } as any, '/foo/bar', 'README.md')
-      expect(terminal.sendText).toBeCalledWith('git clone /foo/bar "/bar/foo"')
-      expect(terminal.dispose).toBeCalledTimes(1)
-      expect(progress.report).toBeCalledTimes(1)
+
+      expect(window.showErrorMessage).toBeCalledTimes(1)
     })
 
     it('should finish clone process', async () => {
-      vi.mocked(Uri.parse).mockReturnValue('some url' as any)
-      vi.mocked(waitForProjectCheckout).mockImplementation(
-        async (_, __, ___, resolve) => resolve(true))
+      vi.mocked(tasks.executeTask).mockResolvedValue({ _id: 'id' } as any)
+      vi.mocked(tasks.onDidEndTaskProcess).mockImplementation((x) => x({
+        execution: {
+          _id: 'id'
+        },
+        exitCode: 0
+      } as any))
+
       await handler['_cloneProject'](progress, { fsPath: '/bar/foo' } as any, '/foo/bar', 'README.md')
+
       expect(progress.report).toBeCalledWith({ increment: 100 })
-      expect(terminal.dispose).toBeCalledTimes(1)
       expect(commands.executeCommand).toBeCalledWith(
         'vscode.openFolder',
         { 'fsPath': '/bar/foo' },