diff --git a/docs/architecture/cli-output-in-terminal.md b/docs/architecture/cli-output-in-terminal.md index 038913c..190db61 100644 --- a/docs/architecture/cli-output-in-terminal.md +++ b/docs/architecture/cli-output-in-terminal.md @@ -1,6 +1,6 @@ # CLI output is echoed into xterm, not run inside the shell -GitMastery commands started from the app (`setup`, `download`, `verify`) still run as a +GitMastery commands started from the app (`download`, `verify`) still run as a separate `child_process.spawn`. Their stdout and stderr are painted into the xterm pane so `INFO` lines stay in scrollback instead of a toast. diff --git a/docs/architecture/cli-resolution.md b/docs/architecture/cli-resolution.md new file mode 100644 index 0000000..44c297f --- /dev/null +++ b/docs/architecture/cli-resolution.md @@ -0,0 +1,28 @@ +# CLI resolution is the learner's PATH + +Spawned Git-Mastery commands (`download`, `verify`) and the in-app terminal resolve +`gitmastery` the same way: they inherit `getCliEnvironment()`. That is the PATH the +app was launched with, plus Homebrew prefixes on macOS. The app does not install +the CLI and does not add any other directory. + +## Why the app does not install the CLI + +Windows and Linux used to download a binary into the learner's save folder and +prepend that folder to PATH. That made a self-install invisible to Verify, and it +put a learner-chosen folder (often Downloads or Documents) ahead of system tools, +so a stray executable there could shadow `git`. macOS installed through Homebrew +instead, so the app had two install models that failed in different ways. + +The lessons already tell learners to install the CLI and run `gitmastery setup`. +The app follows that and only checks that the result is visible. + +## Known limits + +- `process.env` is a snapshot from app launch. On Windows, a CLI added to PATH + while the app is running stays invisible until restart. macOS and Linux installs + land in directories already on the launch PATH (Homebrew prefixes are added by + the app; apt installs into `/usr/bin`), so they show up without a restart. +- A binary an older build downloaded into the save folder is no longer on PATH. + Those learners install the CLI the way the lessons describe. +- Windows resolution looks for `gitmastery.exe` only. Shim-based installs + (`.cmd` / `.bat`) are unsupported; running those needs `shell: true`. diff --git a/docs/architecture/exercise-directory-resolution.md b/docs/architecture/exercise-directory-resolution.md index 6c2c4d0..3e04d50 100644 --- a/docs/architecture/exercise-directory-resolution.md +++ b/docs/architecture/exercise-directory-resolution.md @@ -253,8 +253,7 @@ is already in the terminal. Failures, including a failed download, settle the sa re-downloads. Blocked on the flag shipping — it does not exist in v7.8.2. When picked up it needs: -- a minimum CLI version gate (`get-gitmastery-version` in `src/electron/ipc/setupPrereq.ts` - already reads the version) +- a minimum CLI version gate (the app no longer reads the CLI version; that would need a new probe) - a destructive-action confirmation, since it deletes the learner's work - a fallback for older CLIs: hide the button rather than shell out to a flag that errors @@ -287,7 +286,7 @@ folder, so a stray `cd` into a subdirectory is sent back through Start. ## 10. Process spawn failures -`_setup`, `_download` and `_verify` each listen for `'error'` on the child process. A spawn that +`_download` and `_verify` each listen for `'error'` on the child process. A spawn that never starts — GitMastery missing from `PATH`, exercise folder gone — emits `'error'` and then usually `'close'` with `code === null`. Without an `error` listener Node raises this as an uncaught exception in the main process. `_download` must still settle its promise on both diff --git a/docs/architecture/exercise-folder-location.md b/docs/architecture/exercise-folder-location.md deleted file mode 100644 index 1d57571..0000000 --- a/docs/architecture/exercise-folder-location.md +++ /dev/null @@ -1,23 +0,0 @@ -# Exercise folder location is locked after first setup - -GitMastery writes exercise files under `{dataDirectory}/gitmastery-exercises`. The learner -picks `dataDirectory` once, during onboarding (or from Settings if they skipped that step). -Changing it afterwards is deferred. - -## Why lock it - -The folder is not just a preference. The GitMastery CLI (on Windows and Linux) is downloaded -into the same parent, progress is keyed off paths under it, and in-progress work lives on -disk there. Relocating after that is a migration, not a settings edit — and migration is not -built. - -The lock is keyed on `check-exercise-folder`'s `ready` flag (the `gitmastery-exercises` -directory exists), not merely on `dataDirectory` being set. A chosen-but-uninitialised path -can still be changed; once Setup has created the folder, the picker is read-only. - -## Rejected alternatives - -- **Hardcoded default, no picker.** Would skip a real first-run choice (Documents vs Desktop - vs a dedicated drive), and would hide the path from the learner. -- **Allow changes with a migration.** The right long-term shape, but it needs copying work, - re-pointing the CLI binary, and resetting progress cache. Deferred rather than half-done. diff --git a/docs/architecture/exercises-root.md b/docs/architecture/exercises-root.md new file mode 100644 index 0000000..1f64ab4 --- /dev/null +++ b/docs/architecture/exercises-root.md @@ -0,0 +1,65 @@ +# The exercises root is one the learner already created + +The app stores `exercisesRoot`: the folder `gitmastery setup` created, which is the +folder containing `.gitmastery.json`. It is not the parent, and the app never +creates it. + +## Why the app does not run setup + +Learners who follow the lessons in order are told not to set Git-Mastery up in +advance. The lessons introduce the CLI and `gitmastery setup` when they are +needed. Running setup from an onboarding screen asked for that work before the +lesson did, and it required the CLI to be installed first. + +The first time a learner clicks Start, the app walks through three steps. The +introduction is shown once. The other two are checked on every Start, from the +main process, so the Start button in the app and the one in the lesson page +behave the same. + +1. **Introduction.** A short description of an exercise: a folder of starting + files, work in the terminal, then Verify. Hands-on practicals are mentioned + because they have no Verify step. +2. **Tools.** `gitmastery` must be on PATH. On Windows, Git Bash must be + installed too, because the in-app terminal uses it. Neither check runs the + CLI. On Windows the step says to restart the app, because PATH is read at + launch and a retry cannot see a change. On macOS and Linux the step can be + checked again immediately. +3. **Folder.** The learner picks the exercises folder. It is accepted when it + contains `.gitmastery.json`. If they pick the parent and exactly one + subfolder is a root, that subfolder is used. The copy says this step does + not run setup. "Setup Instructions" in that sentence opens T1L2 + (`/lessons/gitPrep/#installing-the-git-mastery-app`) in the embedded site + and closes the dialog, because the native view is hidden while a dialog is + open. An X beside the path forgets the link. It does not delete the folder. + +Git, `user.name` and `user.email` are not checked separately. `gitmastery setup` +refuses to finish unless they are in place, and only a finished setup writes +`.gitmastery.json`. A valid root already proves those checks passed. Running +`gitmastery check git` would add several seconds and is skipped. GitHub CLI +requirements are not listed per exercise; `gitmastery download` reports them. + +Closing the steps does not show an error. The next Start resumes at whichever +step is still missing. Once every step passes, the exercise starts on its own. + +## Why there is no lock + +The app owns no files in the root, so pointing Settings at a different one is a +setting change. Progress is read from disk again. Older builds stored the parent +folder and assumed the child was named `gitmastery-exercises`, which broke +learners who accepted setup's offer to rename it. Storing the root itself allows +any name. An existing parent-folder setting is migrated on launch when +`gitmastery-exercises` exists underneath it. + +The folder step includes a fixed warning against OneDrive, Dropbox, Google Drive +and iCloud. The path is not inspected: iCloud sync of Desktop and Documents does +not appear in the path, so a check would miss the case the warning exists for. + +## Rejected alternatives + +- **The app runs `gitmastery setup`.** Needs the CLI first, and repeats a step + the lesson already asks for. Also forced the folder name and location. +- **Guess the root** by scanning the home folder, or by noticing the terminal is + inside one. Scanning is slow and can find more than one. Tracking the terminal + folder depends on parsing `cd`, which misses other ways of moving. +- **Detect cloud-synced folders from the path.** Misses iCloud. A fixed hint is + shown instead. diff --git a/src/electron/exerciseProgress.ts b/src/electron/exerciseProgress.ts index 1066d13..b994bf0 100644 --- a/src/electron/exerciseProgress.ts +++ b/src/electron/exerciseProgress.ts @@ -32,7 +32,7 @@ export function getExerciseProgress(): ProgressData { } function computeExerciseProgress(): ProgressData { - if (!getConfig().dataDirectory) return {}; + if (!getConfig().exercisesRoot) return {}; let exerciseDirectory: string; try { diff --git a/src/electron/ipc/config.ts b/src/electron/ipc/config.ts index 3c7cf5f..126d113 100644 --- a/src/electron/ipc/config.ts +++ b/src/electron/ipc/config.ts @@ -1,12 +1,11 @@ import { dialog, BrowserWindow } from "electron"; -import { getConfig, saveConfig } from "../storage.js"; -import { ipcMainHandle, ipcMainOn } from "../utils/util.js"; +import { clearExerciseRoot, getConfig, saveConfig } from "../storage.js"; +import { ipcMainHandle } from "../utils/util.js"; import { getExerciseProgress, resetExerciseProgressCache, } from "../exerciseProgress.js"; -import fs from "fs"; -import path from "path"; +import { resolveExerciseRootPick } from "../startPrereqs.js"; export function setupConfigIpc(mainWindow: BrowserWindow) { ipcMainHandle("select-folder", async () => { @@ -21,28 +20,23 @@ export function setupConfigIpc(mainWindow: BrowserWindow) { return result.filePaths[0]; }); - ipcMainOn("set-data-directory", ({ directory }) => { - console.log("[info] set-data-directory event: ", directory); - saveConfig({ dataDirectory: directory }); - resetExerciseProgressCache(); + ipcMainHandle("get-exercise-root", async () => { + return { root: getConfig().exercisesRoot ?? null }; }); - ipcMainHandle("get-data-directory", async () => { - return getConfig().dataDirectory || null; - }); + ipcMainHandle("set-exercise-root", async ({ directory }) => { + const resolved = resolveExerciseRootPick(directory); + if (!resolved.ok) return resolved; - ipcMainHandle("check-exercise-folder", async () => { - const dataDirectory = getConfig().dataDirectory || null; - if (!dataDirectory) { - return { dataDirectory: null, exercisesPath: null, ready: false }; - } + saveConfig({ exercisesRoot: resolved.root }); + resetExerciseProgressCache(); + return resolved; + }); - const exercisesPath = path.join(dataDirectory, "gitmastery-exercises"); - return { - dataDirectory, - exercisesPath, - ready: fs.existsSync(exercisesPath), - }; + ipcMainHandle("clear-exercise-root", async () => { + clearExerciseRoot(); + resetExerciseProgressCache(); + return true; }); ipcMainHandle("get-downloaded-exercises", async () => { diff --git a/src/electron/ipc/gitmastery.ts b/src/electron/ipc/gitmastery.ts index b16baaa..a6d9d73 100644 --- a/src/electron/ipc/gitmastery.ts +++ b/src/electron/ipc/gitmastery.ts @@ -1,14 +1,13 @@ import { BrowserWindow } from "electron"; -import fs from "fs"; import path from "path"; import { spawn } from "child_process"; import { ipcMainHandle } from "../utils/util.js"; -import { getConfig } from "../storage.js"; import { logGM } from "../utils/logger.js"; import { - getEnvironmentWithHomebrew, + CLI_BINARY, + getCliEnvironment, getExerciseDirectory, - getGitMasteryExecutable, + resolveGitMasteryBinary, } from "../utils/cli/getters.js"; import { patchExerciseProgress } from "../exerciseProgress.js"; import { @@ -23,6 +22,11 @@ import { reprintPrompt, } from "./terminal.js"; import { sendToRenderer } from "./ipcUtils.js"; +import { + getBlockingPrereq, + getStartPrereqStep, + prereqFailureMessage, +} from "../startPrereqs.js"; const GM_TASK_DATA_CHANNEL = "gitmastery-task-data" as const; const START_EXERCISE_STARTED_CHANNEL = "start-exercise-started" as const; @@ -67,9 +71,9 @@ const _spawnChildProcess = ({ args: string[]; cwd?: string; }) => { - return spawn(getGitMasteryExecutable(), args, { + return spawn(resolveGitMasteryBinary() ?? CLI_BINARY, args, { cwd, - env: getEnvironmentWithHomebrew(), + env: getCliEnvironment(), }); }; @@ -99,18 +103,27 @@ const startCliEcho = (commandLabel: string) => { * folder gone) as a task failure. Without an `error` listener Node throws this * as an uncaught exception and `close` never fires. */ +const spawnFailureMessage = (err: Error) => { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return "Git-Mastery CLI not found. Install it from Settings, or restart the app if you just installed it yourself."; + } + return `Could not run Git-Mastery: ${err.message}`; +}; + const _reportSpawnFailure = ( mainWindow: BrowserWindow, originalCommand: string, exerciseIdentifier: string | undefined, err: Error, ) => { - logGM("close", originalCommand, err.message); + const message = spawnFailureMessage(err); + logGM("close", originalCommand, message); const taskPayload: GitMasteryTaskData = { exerciseIdentifier, completed: { status: "failure", - message: `Could not run Git-Mastery: ${err.message}`, + message, }, }; sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { @@ -119,166 +132,6 @@ const _reportSpawnFailure = ( }); }; -const _setup = async (mainWindow: BrowserWindow) => { - const exeLocation = getGitMasteryExecutable(); - const dataDirectory = getConfig().dataDirectory; - - console.log({ exeLocation, dataDirectory }); - - // 1. Check if the data directory exists. - // Reported as a failed task rather than thrown, so the renderer settles the - // same way it does for any other setup failure. - if (!dataDirectory || !fs.existsSync(dataDirectory)) { - console.log("error: data directory not found"); - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: { - completed: { - status: "failure", - message: - "No save location configured. Choose where exercise files should live first.", - }, - }, - }); - return; - } - - // 2a. Check if the exe exists (windows only) — auto-download if missing - // if (process.platform === "win32" && !fs.existsSync(exeLocation)) { - - // logGM('download', 'exe', 'gitmastery.exe not found — downloading latest release...'); - // await downloadGitMasteryExe(dataDirectory); - // logGM('download', 'exe', 'Download complete.'); - // } - - // 2b. Check if gitmastery is installed using brew (Mac only) - // TODO - - // 3. Check if the exercises folder is created - const exerciseDirectory = path.join(dataDirectory, "gitmastery-exercises"); - if (!fs.existsSync(exerciseDirectory)) { - // run setup process - // Spawn the process - // Do NOT use shell: true — it causes cmd.exe to split on spaces in the path, - // e.g. "C:\Coding\gitmastery stuff\gitmastery.exe" gets truncated to "C:\Coding\gitmastery" - // Use dataDirectory as cwd because the exercises subdirectory - // doesn't exist yet — setup is what creates it. Using the default - // cwd (getExerciseDirectory()) would cause spawn to fail with ENOENT. - const childProcess = _spawnChildProcess({ - args: ["setup"], - cwd: dataDirectory, - }); - const echo = startCliEcho("gitmastery setup"); - - let stdoutBuffer = ""; - let stderrBuffer = ""; - - childProcess.stdout.on("data", (data) => { - stdoutBuffer += data.toString() + "[[terminal-line]]"; - // Send progress updates to renderer - logGM("stdout", "setup", data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - success: { - message: data.toString(), - data: { - stdout: stdoutBuffer, - stderr: stderrBuffer, - }, - }, - }; - - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: taskPayload, - }); - - if (data.toString().includes("PROMPT")) { - childProcess.stdin.write("\n"); - childProcess.stdin.end(); // no more input - } - }); - - childProcess.stderr.on("data", (data) => { - stderrBuffer += data.toString() + "[[terminal-line]]"; - // Send error updates to renderer - logGM("stderr", "setup", data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - error: { - message: data.toString(), - code: 500, - }, - }; - - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: taskPayload, - }); - }); - - childProcess.on("error", (err) => { - echo.write(`Could not run Git-Mastery: ${err.message}`); - echo.finish(); - _reportSpawnFailure(mainWindow, "setup", undefined, err); - }); - - childProcess.on("close", (code) => { - echo.finish(); - logGM("close", "setup", String(code)); - if (code === 0) { - // Success - - const taskPayload: GitMasteryTaskData = { - completed: { - status: "success", - message: "Setup finished", - }, - }; - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: taskPayload, - }); - } else { - // Failure - - const taskPayload: GitMasteryTaskData = { - completed: { - status: "failure", - message: stderrBuffer || "Setup failed. Try again.", - stdout: stdoutBuffer, - stderr: stderrBuffer, - }, - }; - - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: taskPayload, - }); - } - }); - - return; - } - - // else, nothing to setup - const taskPayload: GitMasteryTaskData = { - completed: { - status: "success", - message: "Setup complete", - }, - }; - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: "setup", - data: taskPayload, - }); - - console.log("nothing to setup for gitmastery setup", taskPayload); - return; -}; - /** * Runs `gitmastery download `, streaming progress to the renderer. * Resolves true once the CLI exits successfully, so callers can chain work — @@ -371,7 +224,7 @@ export const _download = ( }); childProcess.on("error", (err) => { - echo.write(`Could not run Git-Mastery: ${err.message}`); + echo.write(spawnFailureMessage(err)); echo.finish(); _reportSpawnFailure( mainWindow, @@ -439,6 +292,20 @@ export const _verify = ( mainWindow: BrowserWindow, exerciseIdentifier: string, ) => { + const blocking = getBlockingPrereq(); + if (blocking) { + const message = prereqFailureMessage(blocking); + echoToXterm(`\r\n${message}\r\n`); + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, + data: { + exerciseIdentifier, + completed: { status: "failure", message }, + }, + }); + return; + } + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { originalCommand: `verify`, data: { @@ -511,7 +378,7 @@ export const _verify = ( }); childProcess.on("error", (err) => { - echo.write(`Could not run Git-Mastery: ${err.message}`); + echo.write(spawnFailureMessage(err)); echo.finish(); _reportSpawnFailure(mainWindow, "verify", exerciseIdentifier, err); }); @@ -581,25 +448,33 @@ const _startExercise = async ( mainWindow: BrowserWindow, exerciseIdentifier: string, ): Promise => { - const generation = ++startGeneration; - const cdIfCurrent = (directory: string) => { - if (generation === startGeneration) changeDirectory(directory); - }; - // The outcome is broadcast as well as returned, so that the button injected // into the embedded lesson page, which has no return value to inspect, drives - // the same onboarding and error handling as the app's own button. + // the same error handling as the app's own button. const report = ( result: StartExerciseResult, { broadcast = true }: { broadcast?: boolean } = {}, ): StartExerciseResult => { - if (!result.ok) console.warn(`[start-exercise] ${result.error}`); + if (!result.ok && result.error) + console.warn(`[start-exercise] ${result.error}`); if (broadcast) { sendToRenderer(mainWindow, START_EXERCISE_RESULT_CHANNEL, result); } return result; }; + // Checked before taking a start generation, so a missing tool does not + // cancel a download that is already changing directory. + const firstRunStep = getStartPrereqStep(); + if (firstRunStep) { + return report({ ok: false, exerciseIdentifier, firstRunStep }); + } + + const generation = ++startGeneration; + const cdIfCurrent = (directory: string) => { + if (generation === startGeneration) changeDirectory(directory); + }; + if (!isPathSegment(exerciseIdentifier)) { return report({ ok: false, @@ -712,9 +587,6 @@ export const startExercise = ( // Handles backend gitmastery ipc events // responsible for downloads, verification, etc export function setupGitmasteryIpc(mainWindow: BrowserWindow) { - // command 1: `gitmastery setup` - // prerequisites: must have chosen an exe location and exercise directory - // action: spawn terminal, cd to exercise directory, run `[exe location] setup` ipcMainHandle( "gitmastery-start-task", async ({ command }: { command: string }) => { @@ -726,9 +598,6 @@ export function setupGitmasteryIpc(mainWindow: BrowserWindow) { const commandArgs = commandParts.slice(1); switch (commandName) { - case "setup": - await _setup(mainWindow); - break; case "download": // Routed through startExercise so this path keeps the guard against // downloading over an exercise that already exists. diff --git a/src/electron/ipc/setupPrereq.ts b/src/electron/ipc/setupPrereq.ts index 523e418..a6880c4 100644 --- a/src/electron/ipc/setupPrereq.ts +++ b/src/electron/ipc/setupPrereq.ts @@ -1,145 +1,9 @@ import { shell } from "electron"; -import { ipcMainHandle, ipcMainOn } from "../utils/util.js"; -import { exec, execFile } from "child_process"; -import { promisify } from "util"; -import { - getGitMasteryExecutable, - getEnvironmentWithHomebrew, -} from "../utils/cli/getters.js"; -import { getConfig } from "../storage.js"; -import { logGM } from "../utils/logger.js"; -import { downloadGitMasteryExe } from "../utils/win32/downloadExe.js"; -import { downloadApp as downloadAppDarwin } from "../utils/darwin/downloadApp.js"; -import { downloadApp as downloadAppLinux } from "../utils/linux/downloadApp.js"; -import fs from "fs"; - -const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); +import { ipcMainOn } from "../utils/util.js"; export const setupPrereqIpc = () => { - ipcMainHandle("download-gitmastery-app", async () => { - try { - await downloadGitMasteryApp(); - return true; - } catch { - return false; - } - }); - - // empty string --> not downloaded - // - ipcMainHandle("get-gitmastery-version", async () => { - console.log("getgitmasteryversion"); - // windows - if (process.platform === "win32") { - const exeLocation = getGitMasteryExecutable(); - const exists = fs.existsSync(exeLocation); - if (!exists) return { version: "" }; - const { stdout } = await execFileAsync(exeLocation, ["version"]); - return parseOutput(stdout); - } - // mac - if (process.platform === "darwin") { - // gitmastery may not be installed yet — treat a missing command as version "" - try { - const { stdout } = await execAsync("gitmastery version", { - env: getEnvironmentWithHomebrew(), - }); - return parseOutput(stdout); - } catch { - return { version: "" }; - } - } - if (process.platform === "linux") { - const binaryLocation = getGitMasteryExecutable(); - const exists = fs.existsSync(binaryLocation); - if (!exists) return { version: "" }; - const { stdout } = await execFileAsync(binaryLocation, ["version"]); - return parseOutput(stdout); - } - - return { version: "" }; - }); - // Open a URL in the system's default browser. - // Uses shell.openExternal which is the Electron-safe way to open external links. ipcMainOn("open-external", ({ url }: { url: string }) => { shell.openExternal(url); }); }; - -async function downloadGitMasteryApp() { - const exeLocation = getGitMasteryExecutable(); - const dataDirectory = getConfig().dataDirectory; - - console.log({ exeLocation, dataDirectory }); - - // 1. Check if the data directory exists - if (!dataDirectory || !fs.existsSync(dataDirectory)) { - throw new Error( - "Exercise directory not found - maybe you haven't chosen a save directory yet?", - ); - } - - // 2a. Check if the exe exists (windows only) — auto-download if missing - if ( - process.platform === "win32" - // && !fs.existsSync(exeLocation) - ) { - logGM("download", "exe", "Downloading gitmastery.exe from Github..."); - await downloadGitMasteryExe(dataDirectory); - logGM("download", "exe", "Download complete."); - } - - // 2b. Install via Homebrew (macOS only) - if (process.platform === "darwin") { - logGM("download", "darwin", "Installing gitmastery via Homebrew..."); - await downloadAppDarwin(); - logGM("download", "darwin", "Done."); - } - - // 2c. Download binary from GitHub releases (Linux only) - if (process.platform === "linux") { - logGM( - "download", - "linux", - "Downloading gitmastery binary from GitHub releases...", - ); - await downloadAppLinux(dataDirectory); - logGM("download", "linux", "Done."); - } -} - -/** - * WARN Your version of Git-Mastery app v7.7.0 is behind the latest version v7.8.2. - * WARN We strongly recommend upgrading your app. - * WARN Follow the update guide here: https://git-mastery.org/companion-app/index.html#updating-the-git-mastery-app - * INFO Git-Mastery app is v7.7.0 - */ -export function parseOutput(stdout: string): { - version: string; - latest?: string; -} { - let version = ""; - let latest: string | undefined = undefined; - - const warnMatch = stdout.match( - /behind the latest version v([\d]+(?:\.[\d]+)*)/, - ); - if (warnMatch) { - latest = warnMatch[1]; - } - - const infoMatch = stdout.match(/Git-Mastery app is v([\d]+(?:\.[\d]+)*)/); - if (infoMatch) { - version = infoMatch[1]; - } else { - // Fallback: just try to find vX.Y.Z - const fallbackMatch = stdout.match(/v([\d]+(?:\.[\d]+)*)/); - if (fallbackMatch) { - version = fallbackMatch[1]; - } - } - - return { version, latest }; -} diff --git a/src/electron/ipc/terminal.ts b/src/electron/ipc/terminal.ts index 212b417..1c0f99c 100644 --- a/src/electron/ipc/terminal.ts +++ b/src/electron/ipc/terminal.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { execSync } from "child_process"; import pty from "node-pty"; import { ipcMainOn } from "../utils/util.js"; +import { getCliEnvironment } from "../utils/cli/getters.js"; /** * On Windows, attempts to find Git Bash (bash.exe) by locating the git @@ -15,7 +16,7 @@ import { ipcMainOn } from "../utils/util.js"; * * Returns null if git is not on PATH or bash.exe cannot be found. */ -function findGitBash(): string | null { +export function findGitBash(): string | null { if (os.platform() !== "win32") return null; try { // `where git` may return multiple lines; take the first valid one @@ -191,7 +192,7 @@ export function setupTerminalIpc(mainWindow: BrowserWindow) { cols, rows, cwd, - env: process.env, + env: getCliEnvironment(), }); // Queued commands are replayed on the shell's first output rather than diff --git a/src/electron/main.ts b/src/electron/main.ts index 50890a8..1e3755c 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -7,6 +7,7 @@ import { setupGitmasteryIpc } from "./ipc/gitmastery.js"; import { setupWebContentsViewIpc } from "./ipc/webContentsView.js"; import { setupConfigIpc } from "./ipc/config.js"; import { setupPrereqIpc } from "./ipc/setupPrereq.js"; +import { setupStartPrereqIpc } from "./startPrereqs.js"; import { readStoredThemePreference, resolveFromOs, @@ -41,6 +42,7 @@ app.on("ready", () => { setupWebContentsViewIpc(mainWindow); setupConfigIpc(mainWindow); setupPrereqIpc(); + setupStartPrereqIpc(); console.log("isDev: ", isDev()); if (isDev()) { diff --git a/src/electron/preload.cts b/src/electron/preload.cts index a248361..8ce1f71 100644 --- a/src/electron/preload.cts +++ b/src/electron/preload.cts @@ -26,15 +26,13 @@ contextBridge.exposeInMainWorld("electron", { }) => ipcSend("set-app-theme", payload), // Config - setDataDirectory: (directory: string) => - ipcSend("set-data-directory", { directory }), - getDataDirectory: () => ipcInvoke("get-data-directory", null), selectFolder: () => ipcInvoke("select-folder", null), - - // Setup - downloadGitMasteryApp: () => ipcInvoke("download-gitmastery-app", null), - getGitMasteryVersion: () => ipcInvoke("get-gitmastery-version", null), - checkExerciseFolder: () => ipcInvoke("check-exercise-folder", null), + getExerciseRoot: () => ipcInvoke("get-exercise-root", null), + setExerciseRoot: (directory: string) => + ipcInvoke("set-exercise-root", { directory }), + clearExerciseRoot: () => ipcInvoke("clear-exercise-root", null), + checkStartPrereqs: () => ipcInvoke("check-start-prereqs", null), + markStartIntroSeen: () => ipcInvoke("mark-start-intro-seen", null), // GitMastery getDownloadedExercises: () => ipcInvoke("get-downloaded-exercises", null), diff --git a/src/electron/startPrereqs.ts b/src/electron/startPrereqs.ts new file mode 100644 index 0000000..8e22538 --- /dev/null +++ b/src/electron/startPrereqs.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import { ipcMainHandle } from "./utils/util.js"; +import { getConfig, saveConfig } from "./storage.js"; +import { + isExerciseRoot, + resolveGitMasteryBinary, +} from "./utils/cli/getters.js"; +import { findGitBash } from "./ipc/terminal.js"; + +const INVALID_ROOT = + "That folder is not a Git-Mastery exercises folder. Run gitmastery setup, then choose the folder it created."; + +export type ToolsStatus = { + cli: boolean; + /** Null off Windows, where Git Bash is not required. */ + gitBash: boolean | null; +}; + +export function getToolsStatus(): ToolsStatus { + return { + cli: resolveGitMasteryBinary() !== null, + gitBash: process.platform === "win32" ? findGitBash() !== null : null, + }; +} + +export function hasValidExerciseRoot(): boolean { + const root = getConfig().exercisesRoot; + return Boolean(root && isExerciseRoot(root)); +} + +/** The first Start step that is still outstanding, ignoring the one-time intro. */ +export function getBlockingPrereq(): "tools" | "folder" | null { + const tools = getToolsStatus(); + if (!tools.cli || tools.gitBash === false) return "tools"; + if (!hasValidExerciseRoot()) return "folder"; + return null; +} + +/** + * Intro, then tools, then the exercises folder. Null means Start can run. + * The intro is skipped by Verify; use `getBlockingPrereq` there. + */ +export function getStartPrereqStep(): FirstRunStep | null { + if (!getConfig().startIntroSeen) return "intro"; + return getBlockingPrereq(); +} + +export function prereqFailureMessage(step: "tools" | "folder"): string { + if (step === "folder") { + return "No exercises folder is set. Start an exercise and choose the folder that gitmastery setup created."; + } + + const tools = getToolsStatus(); + if (!tools.cli && tools.gitBash === false) { + return "Git-Mastery and Git Bash were not found. Install them, then restart the app."; + } + if (tools.gitBash === false) { + return "Git Bash was not found. Install Git for Windows, then restart the app."; + } + if (tools.gitBash === null) { + return "The Git-Mastery CLI was not found. Install it from the lesson instructions, then try again."; + } + return "The Git-Mastery CLI was not found. Install it, then restart the app."; +} + +/** + * Accepts a folder that is an exercises root, or a parent that contains exactly + * one. Learners often pick the folder they ran setup from, one level up. + */ +export function resolveExerciseRootPick( + directory: string, +): { ok: true; root: string } | { ok: false; error: string } { + let stat: fs.Stats; + try { + stat = fs.statSync(directory); + } catch { + return { ok: false, error: INVALID_ROOT }; + } + if (!stat.isDirectory()) return { ok: false, error: INVALID_ROOT }; + if (isExerciseRoot(directory)) return { ok: true, root: directory }; + + let children: string[]; + try { + children = fs.readdirSync(directory); + } catch { + return { ok: false, error: INVALID_ROOT }; + } + + const roots = children.filter((name) => { + const child = path.join(directory, name); + try { + return fs.statSync(child).isDirectory() && isExerciseRoot(child); + } catch { + return false; + } + }); + + if (roots.length === 1) { + return { ok: true, root: path.join(directory, roots[0]) }; + } + return { ok: false, error: INVALID_ROOT }; +} + +export function setupStartPrereqIpc() { + ipcMainHandle("check-start-prereqs", async () => ({ + step: getStartPrereqStep(), + tools: getToolsStatus(), + })); + + ipcMainHandle("mark-start-intro-seen", async () => { + saveConfig({ startIntroSeen: true }); + return true; + }); +} diff --git a/src/electron/storage.ts b/src/electron/storage.ts index da7eb83..64e692f 100644 --- a/src/electron/storage.ts +++ b/src/electron/storage.ts @@ -3,10 +3,17 @@ import path from "path"; import fs from "fs"; interface Config { - // This is where the exercises are downloaded to. The exercises themselves live under ${dataDirectory}/gitmastery-exercises/ - dataDirectory?: string; + /** + * The Git-Mastery exercises root the learner created with `gitmastery setup`. + * This is the folder itself (the one containing `.gitmastery.json`), not its parent. + */ + exercisesRoot?: string; + /** The learner has seen the first-Start introduction. */ + startIntroSeen?: boolean; /** Desktop + site colour preference. System follows the OS. */ theme?: SitePageTheme; + /** Parent folder chosen by older builds. Migrated to `exercisesRoot` on read. */ + dataDirectory?: string; } const appBasePath = app.getPath("userData"); @@ -14,19 +21,49 @@ const configPath = path.join(app.getPath("userData"), "config.json"); export const getUserStoragePath = () => appBasePath; +const readConfigFile = (): Config => { + if (!fs.existsSync(configPath)) return {}; + return JSON.parse(fs.readFileSync(configPath, "utf8")) as Config; +}; + +const writeConfigFile = (config: Config): void => { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); +}; + export function getConfig(): Config { try { - if (!fs.existsSync(configPath)) return {}; - return JSON.parse(fs.readFileSync(configPath, "utf8")); + const config = readConfigFile(); + if (config.exercisesRoot || !config.dataDirectory) return config; + + const legacyRoot = path.join(config.dataDirectory, "gitmastery-exercises"); + if (!fs.existsSync(legacyRoot)) return config; + + const migrated: Config = { ...config, exercisesRoot: legacyRoot }; + delete migrated.dataDirectory; + writeConfigFile(migrated); + return migrated; } catch { return {}; } } +/** Forgets the linked exercises folder. Does not delete anything on disk. */ +export function clearExerciseRoot(): void { + try { + const config = readConfigFile(); + delete config.exercisesRoot; + // Drop the legacy parent too, or the next read would link that folder again. + delete config.dataDirectory; + writeConfigFile(config); + } catch (err) { + console.error("Failed to write config:", err); + } +} + export function saveConfig(partial: Partial): void { try { const merged = { ...getConfig(), ...partial }; - fs.writeFileSync(configPath, JSON.stringify(merged, null, 2), "utf8"); + writeConfigFile(merged); } catch (err) { console.error("Failed to write config:", err); } diff --git a/src/electron/utils/cli/getters.ts b/src/electron/utils/cli/getters.ts index 2c72eba..7bb7059 100644 --- a/src/electron/utils/cli/getters.ts +++ b/src/electron/utils/cli/getters.ts @@ -1,61 +1,82 @@ +import fs from "fs"; import path from "path"; import { getConfig } from "../../storage.js"; -// Helper function to get the correct gitmastery executable based on platform -export function getGitMasteryExecutable(): string { - if (process.platform === "darwin") { - return "gitmastery"; - } +export const CLI_BINARY = + process.platform === "win32" ? "gitmastery.exe" : "gitmastery"; - if (process.platform === "linux") { - return path.join(getConfig().dataDirectory!, "gitmastery"); - } +/** Written by `gitmastery setup` into the exercises root. */ +export const EXERCISE_ROOT_MARKER = ".gitmastery.json"; - // on Windows - return path.join(getConfig().dataDirectory!, "gitmastery.exe"); -} +const MACOS_HOMEBREW_PATHS = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/homebrew/sbin", + "/usr/local/sbin", +]; -// Helper function to get environment with Homebrew paths added -export function getEnvironmentWithHomebrew(): NodeJS.ProcessEnv { +const MACOS_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + +/** Windows env keys are case-insensitive; a spread copy of process.env is not. */ +const pathKey = (env: NodeJS.ProcessEnv) => + Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; + +/** + * PATH for spawned CLI / PTY processes. On macOS, prepends Homebrew prefixes + * that GUI launches otherwise miss. Nothing else is added: the CLI is installed + * by the learner, and a learner-chosen folder must not sit ahead of system tools. + */ +export function getCliEnvironment(): NodeJS.ProcessEnv { const env = { ...process.env }; + const key = pathKey(env); - if (process.platform === "darwin") { - // On macOS, add common Homebrew paths to PATH - const homebrewPaths = [ - "/opt/homebrew/bin", // Apple Silicon Macs - "/usr/local/bin", // Intel Macs - "/opt/homebrew/sbin", - "/usr/local/sbin", - ]; + const inherited = env[key]; + const fallback = + process.platform === "darwin" + ? MACOS_SYSTEM_PATHS.join(path.delimiter) + : ""; + const current = inherited && inherited.length > 0 ? inherited : fallback; - // Standard system paths that should always be included - const systemPaths = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + const prefix = process.platform === "darwin" ? MACOS_HOMEBREW_PATHS : []; - // Get current PATH or use system paths as fallback - const currentPath = env.PATH || systemPaths.join(":"); + const unique = Array.from( + new Set( + [...prefix, ...current.split(path.delimiter)].filter((p) => p.length > 0), + ), + ); - // Combine Homebrew paths with current PATH - // Put Homebrew paths first so they take precedence - const allPaths = [...homebrewPaths, ...currentPath.split(":")]; + env[key] = unique.join(path.delimiter); + return env; +} - // Remove duplicates while preserving order - const uniquePaths = Array.from(new Set(allPaths)).filter( - (p) => p.length > 0, - ); +const isFile = (candidate: string): boolean => + fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false; - env.PATH = uniquePaths.join(":"); +/** Absolute path to the CLI, or null when nothing on PATH provides it. */ +export function resolveGitMasteryBinary(): string | null { + const env = getCliEnvironment(); + const dirs = (env[pathKey(env)] ?? "") + .split(path.delimiter) + .filter((p) => p.length > 0); - // Debug logging to help diagnose PATH issues - console.log("Enhanced PATH for macOS:", env.PATH); + for (const dir of dirs) { + const candidate = path.join(dir, CLI_BINARY); + if (isFile(candidate)) return candidate; } + return null; +} - return env; +/** True when `directory` is a Git-Mastery exercises root. */ +export function isExerciseRoot(directory: string): boolean { + return isFile(path.join(directory, EXERCISE_ROOT_MARKER)); } export function getExerciseDirectory(): string { - const dataDirectory = getConfig().dataDirectory; - if (!dataDirectory) { - throw new Error("Exercise directory not found. Finish setup to create it."); + const exercisesRoot = getConfig().exercisesRoot; + if (!exercisesRoot) { + throw new Error( + "Exercise folder not set. Choose it when you start an exercise.", + ); } - return path.join(dataDirectory, "gitmastery-exercises"); + return exercisesRoot; } diff --git a/src/electron/utils/darwin/downloadApp.ts b/src/electron/utils/darwin/downloadApp.ts deleted file mode 100644 index c8b7f50..0000000 --- a/src/electron/utils/darwin/downloadApp.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; -import { logGM } from "../logger.js"; -import { getEnvironmentWithHomebrew } from "../cli/getters.js"; - -const execAsync = promisify(exec); - -/** - * Installs or updates the GitMastery CLI on macOS via Homebrew. - * - * Always runs `brew upgrade gitmastery || brew install gitmastery` so that - * clicking the button fetches the latest version regardless of whether - * gitmastery is already present. Throws if Homebrew itself is not installed. - */ -export const downloadApp = async (): Promise => { - const env = getEnvironmentWithHomebrew(); - - // Verify Homebrew is available before attempting anything. - try { - await execAsync("brew --version", { env }); - } catch { - throw new Error( - "Homebrew is not installed. Please install Homebrew from https://brew.sh and then re-run this step.", - ); - } - - // Upgrade if already installed, otherwise install fresh. - // This ensures clicking the button always gets the latest version. - logGM( - "download", - "darwin", - "Running: brew upgrade gitmastery || brew install gitmastery", - ); - try { - const { stdout: tapStdout, stderr: tapStderr } = await execAsync( - "brew tap git-mastery/gitmastery", - ); - if (tapStdout) logGM("download", "darwin", tapStdout.trim()); - if (tapStderr) logGM("download", "darwin", tapStderr.trim()); - const { stdout, stderr } = await execAsync( - "brew upgrade gitmastery || brew install gitmastery", - { env }, - ); - if (stdout) logGM("download", "darwin", stdout.trim()); - if (stderr) logGM("download", "darwin", stderr.trim()); - logGM("download", "darwin", "gitmastery installed/updated successfully."); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to install/update gitmastery via Homebrew: ${message}`, - ); - } -}; diff --git a/src/electron/utils/linux/downloadApp.ts b/src/electron/utils/linux/downloadApp.ts deleted file mode 100644 index 72fe466..0000000 --- a/src/electron/utils/linux/downloadApp.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from "fs"; -import https from "https"; -import path from "path"; -import { logGM } from "../logger.js"; - -/** - * Downloads the latest x64 Linux gitmastery CLI from the GitHub releases - * API into the given destination directory, then marks it as executable. - * - * The release asset is `gitmastery--linux-amd64`. It is saved as - * `gitmastery` so `getGitMasteryExecutable()` can find it. - * - * Follows HTTP redirects (GitHub releases use a CDN redirect before serving - * the binary). - */ -const LINUX_AMD64_ASSET = /^gitmastery-\d+\.\d+\.\d+-linux-amd64$/; - -export const downloadApp = (destDir: string): Promise => { - const RELEASES_API = - "https://api.github.com/repos/git-mastery/app/releases/latest"; - const DEST_NAME = "gitmastery"; - - return new Promise((resolve, reject) => { - const apiOpts = { - headers: { - "User-Agent": "electron-git-mastery", - Accept: "application/vnd.github+json", - }, - }; - - // Step 1 – resolve the download URL from the releases API - https - .get(RELEASES_API, apiOpts, (res) => { - let raw = ""; - res.on("data", (chunk: Buffer) => { - raw += chunk.toString(); - }); - res.on("error", reject); - res.on("end", () => { - let downloadUrl: string; - try { - const release = JSON.parse(raw) as { - assets: { name: string; browser_download_url: string }[]; - }; - const asset = release.assets.find((a) => - LINUX_AMD64_ASSET.test(a.name), - ); - if (!asset) { - return reject( - new Error( - "gitmastery--linux-amd64 not found in the latest GitHub release", - ), - ); - } - downloadUrl = asset.browser_download_url; - } catch (err) { - return reject( - new Error(`Failed to parse GitHub releases API response: ${err}`), - ); - } - - logGM( - "download", - "linux", - `Downloading ${downloadUrl} to ${DEST_NAME}`, - ); - const destPath = path.join(destDir, DEST_NAME); - - // Step 2 – download the binary (following the CDN redirect) - const doDownload = (url: string) => { - https - .get(url, apiOpts, (fileRes) => { - // GitHub returns a 302 redirect to the actual CDN URL - if (fileRes.statusCode === 301 || fileRes.statusCode === 302) { - const redirectUrl = fileRes.headers.location; - if (!redirectUrl) { - return reject( - new Error("Redirect with no Location header"), - ); - } - fileRes.resume(); // discard the body - return doDownload(redirectUrl); - } - - if (fileRes.statusCode !== 200) { - return reject( - new Error( - `Download failed with HTTP ${fileRes.statusCode}`, - ), - ); - } - - fs.mkdirSync(destDir, { recursive: true }); - const out = fs.createWriteStream(destPath); - fileRes.pipe(out); - out.on("finish", () => { - // Mark the binary as executable (chmod +x) - fs.chmodSync(destPath, 0o755); - logGM( - "download", - "linux", - `Saved to ${destPath} and marked executable.`, - ); - resolve(); - }); - out.on("error", reject); - fileRes.on("error", reject); - }) - .on("error", reject); - }; - - doDownload(downloadUrl); - }); - }) - .on("error", reject); - }); -}; diff --git a/src/electron/utils/win32/downloadExe.ts b/src/electron/utils/win32/downloadExe.ts deleted file mode 100644 index 6acbdee..0000000 --- a/src/electron/utils/win32/downloadExe.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "fs"; -import https from "https"; -import { logGM } from "../logger.js"; -import path from "path"; - -/** - * Downloads the latest gitmastery.exe from the GitHub releases API into - * the given destination directory. Follows up to one HTTP redirect (GitHub - * releases use a CDN redirect before serving the binary). - */ -export const downloadGitMasteryExe = (destDir: string): Promise => { - const RELEASES_API = - "https://api.github.com/repos/git-mastery/app/releases/latest"; - const ASSET_NAME = "gitmastery.exe"; - - return new Promise((resolve, reject) => { - const apiOpts = { - headers: { - "User-Agent": "electron-git-mastery", - Accept: "application/vnd.github+json", - }, - }; - - // Step 1 – resolve the download URL from the releases API - https - .get(RELEASES_API, apiOpts, (res) => { - let raw = ""; - res.on("data", (chunk: Buffer) => { - raw += chunk.toString(); - }); - res.on("error", reject); - res.on("end", () => { - let downloadUrl: string; - try { - const release = JSON.parse(raw) as { - assets: { name: string; browser_download_url: string }[]; - }; - const asset = release.assets.find((a) => a.name === ASSET_NAME); - if (!asset) { - return reject( - new Error( - `${ASSET_NAME} not found in the latest GitHub release`, - ), - ); - } - downloadUrl = asset.browser_download_url; - } catch (err) { - return reject( - new Error(`Failed to parse GitHub releases API response: ${err}`), - ); - } - - logGM( - "download", - "exe", - `Downloading ${ASSET_NAME} from ${downloadUrl}`, - ); - const destPath = path.join(destDir, ASSET_NAME); - - // Step 2 – download the binary (following the CDN redirect) - const doDownload = (url: string) => { - https - .get(url, apiOpts, (fileRes) => { - // GitHub returns a 302 redirect to the actual CDN URL - if (fileRes.statusCode === 301 || fileRes.statusCode === 302) { - const redirectUrl = fileRes.headers.location; - if (!redirectUrl) { - return reject( - new Error("Redirect with no Location header"), - ); - } - fileRes.resume(); // discard the body - return doDownload(redirectUrl); - } - - if (fileRes.statusCode !== 200) { - return reject( - new Error( - `Download failed with HTTP ${fileRes.statusCode}`, - ), - ); - } - - fs.mkdirSync(destDir, { recursive: true }); - const out = fs.createWriteStream(destPath); - fileRes.pipe(out); - out.on("finish", () => { - logGM("download", "exe", `Saved to ${destPath}`); - resolve(); - }); - out.on("error", reject); - fileRes.on("error", reject); - }) - .on("error", reject); - }; - - doDownload(downloadUrl); - }); - }) - .on("error", reject); - }); -}; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c81b799..e6f843c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,24 +3,17 @@ import { WebsiteWrapper } from "./components/Website/WebsiteWrapper"; import { Header } from "./components/Header/Header"; import { LessonsPanelToggle, ToursPanel } from "./components/Header/ToursMenu"; import { useEffect, useState } from "react"; -import { Onboarding } from "./pages/Onboarding"; import { ResizeHandle } from "./components/ResizeHandle"; import { DownloadExerciseListener } from "./components/Exercise/DownloadExerciseListener"; import { getSiteSection, useWebContentsView, } from "./contexts/WebContentsViewContext"; -import { useLocalStorage } from "./hooks/useLocalStorage"; - const MIN_MAIN = 320; const MIN_ASIDE = 280; const ASIDE_WIDTH_VAR = "--gm-aside-width"; function App() { - const [onboardingCompleted, setOnboardingCompleted] = useLocalStorage({ - key: "onboarding-completed", - defaultValue: false, - }); const { setEmbeddedVisible, currentUrl } = useWebContentsView(); const [asideWidth, setAsideWidth] = useState(512); const [lessonsPanelOpened, setLessonsPanelOpened] = useState(false); @@ -44,11 +37,6 @@ function App() { ); }, [asideWidth]); - if (!onboardingCompleted) - return ( - setOnboardingCompleted(true)} /> - ); - return ( <> diff --git a/src/ui/components/Header/SettingsMenu.tsx b/src/ui/components/Header/SettingsMenu.tsx index 1bfa229..7113a7b 100644 --- a/src/ui/components/Header/SettingsMenu.tsx +++ b/src/ui/components/Header/SettingsMenu.tsx @@ -1,22 +1,19 @@ import { useState } from "react"; import { IconAdjustmentsHorizontal, - IconChecklist, IconFolder, IconSettings, } from "@tabler/icons-react"; -import { FileLocationPanel } from "../Setup/FileLocationPanel"; -import { SetupChecklist } from "../Setup/SetupChecklist"; +import { ExerciseFolderPanel } from "../Setup/ExerciseFolderPanel"; import { SiteViewPanel } from "./SiteViewPanel"; import { IconButton } from "../ui/IconButton"; import { Menu, MenuItem, MenuLabel } from "../ui/Menu"; import { Modal } from "../ui/Modal"; -type SettingsPanel = "file-location" | "setup" | "site-view"; +type SettingsPanel = "exercise-folder" | "site-view"; const PANEL_TITLES: Record = { - "file-location": "File location", - setup: "Setup", + "exercise-folder": "Exercise folder", "site-view": "Customise UI", }; @@ -36,15 +33,9 @@ export const SettingsMenu = () => { Setup } - onClick={() => setPanel("file-location")} + onClick={() => setPanel("exercise-folder")} > - {PANEL_TITLES["file-location"]} - - } - onClick={() => setPanel("setup")} - > - {PANEL_TITLES.setup} + {PANEL_TITLES["exercise-folder"]} } @@ -54,17 +45,15 @@ export const SettingsMenu = () => { - {/* The panels are rendered here rather than through the imperative modal - stack, so panels that read app context (SetupChecklist listens to the - task stream) stay inside the providers they depend on. */} setPanel(null)} title={panel ? PANEL_TITLES[panel] : undefined} size="lg" > - {panel === "file-location" && } - {panel === "setup" && } + {panel === "exercise-folder" && ( + setPanel(null)} /> + )} {panel === "site-view" && ( setPanel(null)} /> )} diff --git a/src/ui/components/Setup/ExerciseFolderPanel.tsx b/src/ui/components/Setup/ExerciseFolderPanel.tsx new file mode 100644 index 0000000..71e82c4 --- /dev/null +++ b/src/ui/components/Setup/ExerciseFolderPanel.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { IconFolder, IconX } from "@tabler/icons-react"; +import { + GIT_PREP_SETUP_URL, + useWebContentsView, +} from "../../contexts/WebContentsViewContext"; +import { DOWNLOADED_EXERCISES_QUERY_KEY } from "../../hooks/query/useLocalExercises"; +import { Button } from "../ui/Button"; +import { IconButton } from "../ui/IconButton"; + +/** + * Points the app at the exercises folder the learner already created with + * `gitmastery setup`. Shown from Settings and from the first-Start flow. + */ +export const ExerciseFolderPanel = ({ + onSaved, + onOpenGuide, +}: { + onSaved?: () => void; + /** Closes the panel so the lesson page underneath can be seen. */ + onOpenGuide: () => void; +}) => { + const { navigate } = useWebContentsView(); + const queryClient = useQueryClient(); + const [root, setRoot] = useState(null); + const [error, setError] = useState(null); + const [isPicking, setIsPicking] = useState(false); + + useEffect(() => { + window.electron + .getExerciseRoot() + .then(({ root: saved }) => setRoot(saved)) + .catch(() => setRoot(null)); + }, []); + + const pickFolder = async () => { + setIsPicking(true); + setError(null); + try { + const directory = await window.electron.selectFolder(); + if (!directory) return; + const result = await window.electron.setExerciseRoot(directory); + if (!result.ok) { + setError(result.error); + return; + } + setRoot(result.root); + onSaved?.(); + } finally { + setIsPicking(false); + } + }; + + const unlinkFolder = async () => { + await window.electron.clearExerciseRoot(); + setRoot(null); + setError(null); + queryClient.setQueryData(DOWNLOADED_EXERCISES_QUERY_KEY, {}); + }; + + const openGuide = () => { + navigate(GIT_PREP_SETUP_URL); + onOpenGuide(); + }; + + return ( +
+

+ Select the folder created by{" "} + gitmastery setup. This + step does not set up Git-Mastery on your behalf. Follow the{" "} + {" "} + to set up Git-Mastery first. +

+

+ Don't use a folder synced by OneDrive, Dropbox, Google Drive or + iCloud. +

+ +
+ + {root ? ( + <> + + {root} + + void unlinkFolder()} + > + + + + ) : ( + No folder chosen yet + )} +
+ + {error &&

{error}

} + +
+ +
+
+ ); +}; diff --git a/src/ui/components/Setup/FileLocationPanel.tsx b/src/ui/components/Setup/FileLocationPanel.tsx deleted file mode 100644 index a5a60d0..0000000 --- a/src/ui/components/Setup/FileLocationPanel.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { IconFolder } from "@tabler/icons-react"; -import { Button } from "../ui/Button"; - -/** - * Lets the user pick the folder that exercise files are written to. Shown on - * first run and from Settings. Changing the location is deferred: once the - * exercise folder exists, the picker is locked and the path is read-only. - */ -export const FileLocationPanel = ({ - onChange, -}: { - onChange?: (path: string | null) => void; -}) => { - const [folder, setFolder] = useState(null); - const [locked, setLocked] = useState(false); - const [isPicking, setIsPicking] = useState(false); - const hasLoadedRef = useRef(false); - - useEffect(() => { - if (hasLoadedRef.current) return; - hasLoadedRef.current = true; - - window.electron - .checkExerciseFolder() - .then((status) => { - setFolder(status.dataDirectory); - setLocked(status.ready); - onChange?.(status.dataDirectory); - }) - .catch(() => setFolder(null)); - }, [onChange]); - - const pickFolder = async () => { - setIsPicking(true); - try { - const path = await window.electron.selectFolder(); - if (!path) return; - window.electron.setDataDirectory(path); - setFolder(path); - onChange?.(path); - const status = await window.electron.checkExerciseFolder(); - setLocked(status.ready); - } finally { - setIsPicking(false); - } - }; - - return ( -
-
-

- Practising Git means working with real files on your computer. - Git-Mastery creates a folder for each exercise, with the starting - files already in place. -

- {locked ? ( -

- The exercise folder has already been created here, so this location - cannot be changed. -

- ) : ( -

- Pick a folder you will remember (Documents or Desktop). After the - exercise folder is created, you cannot move it. -

- )} -
- -
- - {folder ? ( - - {folder} - - ) : ( - No folder chosen yet - )} -
- - {!locked && ( -
- -
- )} -
- ); -}; diff --git a/src/ui/components/Setup/FirstStartModal.tsx b/src/ui/components/Setup/FirstStartModal.tsx new file mode 100644 index 0000000..2a9ca09 --- /dev/null +++ b/src/ui/components/Setup/FirstStartModal.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from "react"; +import { Button } from "../ui/Button"; +import { Modal } from "../ui/Modal"; +import { Spinner } from "../ui/Spinner"; +import { + GIT_PREP_SETUP_URL, + useWebContentsView, +} from "../../contexts/WebContentsViewContext"; +import { ExerciseFolderPanel } from "./ExerciseFolderPanel"; + +const TITLES: Record = { + intro: "Before you start", + tools: "Install the tools", + folder: "Choose your exercises folder", +}; + +/** + * Walks a learner through the first Start: a short introduction, then the CLI + * (and Git Bash on Windows), then the exercises folder. Closing it does + * nothing else; the next Start resumes at whichever step is still missing. + */ +export const FirstStartModal = ({ + step, + onClose, + onResolved, +}: { + step: FirstRunStep; + onClose: () => void; + /** Called with the next outstanding step, or null when Start can run. */ + onResolved: (step: FirstRunStep | null) => void; +}) => { + const advance = async () => { + const result = await window.electron.checkStartPrereqs(); + onResolved(result.step); + return result; + }; + + return ( + + {step === "intro" && ( + { + await window.electron.markStartIntroSeen(); + await advance(); + }} + /> + )} + {step === "tools" && ( + + )} + {step === "folder" && ( + void advance()} + onOpenGuide={onClose} + /> + )} + + ); +}; + +const IntroStep = ({ onContinue }: { onContinue: () => Promise }) => { + const [busy, setBusy] = useState(false); + + return ( +
+

+ You'll get a folder with starting files. Work on it in the terminal + on the right, then click Verify to check your answer. +

+

Hands-on practicals work the same way, without a check at the end.

+
+ +
+
+ ); +}; + +const ToolsStep = ({ + onRecheck, + onOpenGuide, +}: { + onRecheck: () => Promise<{ step: FirstRunStep | null; tools: ToolsStatus }>; + onOpenGuide: () => void; +}) => { + const { navigate } = useWebContentsView(); + const [tools, setTools] = useState(null); + const [checking, setChecking] = useState(false); + + useEffect(() => { + window.electron + .checkStartPrereqs() + .then((result) => setTools(result.tools)) + .catch(() => setTools(null)); + }, []); + + const showRecheck = tools !== null && tools.gitBash === null; + + return ( +
+ {tools === null ? ( + + ) : ( +
    + + {tools.gitBash !== null && ( + + )} +
+ )} + + {tools !== null && ( +

+ {showRecheck + ? "Install anything missing, then check again." + : "Install anything missing, then quit and reopen the app so it can see the new tools."} +

+ )} + +
+ {showRecheck && ( + + )} + +
+
+ ); +}; + +const ToolLine = ({ label, ok }: { label: string; ok: boolean }) => ( +
  • + {ok ? `${label} is installed.` : `${label} was not found.`} +
  • +); diff --git a/src/ui/components/Setup/SetupChecklist.tsx b/src/ui/components/Setup/SetupChecklist.tsx deleted file mode 100644 index 03dfc49..0000000 --- a/src/ui/components/Setup/SetupChecklist.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - IconCircleCheck, - IconCircleX, - IconDownload, - IconRefresh, -} from "@tabler/icons-react"; -import { useElectronStream } from "../../hooks/useElectronStream"; -import { IconButton } from "../ui/IconButton"; -import { Spinner } from "../ui/Spinner"; -import { Tooltip } from "../ui/Tooltip"; - -type CheckResult = { ok: boolean; detail: string }; - -type SetupItem = { - key: string; - label: string; - /** Shown until the check has something more specific to report. */ - description: string; - check: () => Promise; - /** Fixes the item from inside the app. */ - install?: { label: string; run: () => Promise }; -}; - -type RowState = { - status: "checking" | "ok" | "missing"; - detail?: string; - busy?: boolean; -}; - -const isSetupCommand = (cmd: string) => cmd.startsWith("setup"); -const noop = () => {}; - -/** - * The prerequisites GitMastery needs, as a checklist that reports its own - * progress inline. Deliberately notification-free: each row owns its spinner, - * result and retry, so it can be shown during first run or from settings. - */ -export const SetupChecklist = ({ - onReadyChange, -}: { - /** Called whenever every item is, or stops being, satisfied. */ - onReadyChange?: (ready: boolean) => void; -}) => { - const [rows, setRows] = useState>({}); - - // `gitmastery setup` reports completion over the task stream rather than from - // its invoke call, so the row waits on this resolver before re-checking. - const setupDoneRef = useRef<(() => void) | null>(null); - - const settleSetup = useCallback(() => { - const resolve = setupDoneRef.current; - setupDoneRef.current = null; - resolve?.(); - }, []); - - useElectronStream({ - condition: isSetupCommand, - onData: noop, - onSuccessExit: settleSetup, - onFailedExit: settleSetup, - }); - - const createExerciseFolder = useCallback( - () => - new Promise((resolve) => { - setupDoneRef.current = resolve; - window.electron.startGitMasteryTask("setup").catch(() => { - setupDoneRef.current = null; - resolve(); - }); - }), - [], - ); - - const items = useMemo( - () => [ - { - key: "gitmastery-cli", - label: "Git-Mastery CLI", - description: "Downloads exercises and runs verify.", - check: async () => { - const { version, latest } = - await window.electron.getGitMasteryVersion(); - if (!version) { - return { ok: false, detail: "Not downloaded yet" }; - } - return { - ok: true, - detail: - latest && latest !== version - ? `Version ${version} installed, ${latest} available` - : `Version ${version}`, - }; - }, - install: { - label: "Download Git-Mastery CLI", - run: () => window.electron.downloadGitMasteryApp(), - }, - }, - { - key: "exercise-folder", - label: "Exercise folder", - description: "Where your exercise files are created.", - check: async () => { - const status = await window.electron.checkExerciseFolder(); - if (!status.dataDirectory) { - return { ok: false, detail: "Choose a save location first" }; - } - return { - ok: status.ready, - detail: status.ready - ? status.exercisesPath! - : "Not created yet in your save location", - }; - }, - install: { label: "Create folder", run: createExerciseFolder }, - }, - ], - [createExerciseFolder], - ); - - const patchRow = useCallback((key: string, patch: Partial) => { - setRows((prev) => { - const current: RowState = prev[key] ?? { status: "checking" }; - return { ...prev, [key]: { ...current, ...patch } }; - }); - }, []); - - const runCheck = useCallback( - async (item: SetupItem) => { - patchRow(item.key, { status: "checking" }); - try { - const { ok, detail } = await item.check(); - patchRow(item.key, { status: ok ? "ok" : "missing", detail }); - } catch (error) { - patchRow(item.key, { - status: "missing", - detail: error instanceof Error ? error.message : "Check failed", - }); - } - }, - [patchRow], - ); - - const checkAll = useCallback(() => { - items.forEach((item) => void runCheck(item)); - }, [items, runCheck]); - - useEffect(() => { - checkAll(); - }, [checkAll]); - - const allReady = items.every((item) => rows[item.key]?.status === "ok"); - useEffect(() => { - onReadyChange?.(allReady); - }, [allReady, onReadyChange]); - - const runInstall = async (item: SetupItem) => { - if (!item.install) return; - patchRow(item.key, { busy: true }); - try { - await item.install.run(); - await runCheck(item); - } finally { - patchRow(item.key, { busy: false }); - } - }; - - return ( -
    -

    Install the required tools to run Git-Mastery.

    - -
    - {items.map((item, index) => { - const state = rows[item.key]; - const status = state?.status ?? "checking"; - - return ( -
    0 - ? "flex items-center justify-between gap-3 border-t border-border py-3" - : "flex items-center justify-between gap-3 py-3" - } - > -
    - -
    - {item.label} - - {state?.detail ?? item.description} - -
    -
    - -
    - {item.install && status !== "ok" && ( - - void runInstall(item)} - > - - - - )} - - void runCheck(item)} - > - - - -
    -
    - ); - })} -
    -
    - ); -}; - -const StatusIcon = ({ status }: { status: RowState["status"] }) => { - if (status === "checking") return ; - if (status === "ok") - return ( - - ); - return ( - - ); -}; diff --git a/src/ui/contexts/WebContentsViewContext.ts b/src/ui/contexts/WebContentsViewContext.ts index 91fd2d3..85e2d10 100644 --- a/src/ui/contexts/WebContentsViewContext.ts +++ b/src/ui/contexts/WebContentsViewContext.ts @@ -6,6 +6,8 @@ import { readStoredGithubUsername } from "../utils/siteViewPrefs"; export const SITE_ORIGIN = "https://git-mastery.org"; export const LESSONS_HOME_URL = `${SITE_ORIGIN}/lessons/`; +/** T1L2, at the section that installs the CLI and runs `gitmastery setup`. */ +export const GIT_PREP_SETUP_URL = `${SITE_ORIGIN}/lessons/gitPrep/#installing-the-git-mastery-app`; export const EXERCISES_HOME_URL = `${SITE_ORIGIN}/exercises-directory/index.html`; export const PROGRESS_HOME_URL = `${SITE_ORIGIN}/progress-dashboard/`; diff --git a/src/ui/pages/Onboarding.tsx b/src/ui/pages/Onboarding.tsx deleted file mode 100644 index 15ca663..0000000 --- a/src/ui/pages/Onboarding.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import logo from "../assets/logo.png"; -import { useEmbeddedSuppressed } from "../hooks/useEmbeddedSuppressed"; -import { FileLocationPanel } from "../components/Setup/FileLocationPanel"; -import { SetupChecklist } from "../components/Setup/SetupChecklist"; -import { Button } from "../components/ui/Button"; -import { Card } from "../components/ui/Card"; -import { Stepper } from "../components/ui/Stepper"; -import { Tooltip } from "../components/ui/Tooltip"; - -const STEPS = ["File location", "Setup"]; - -/** - * First run only. Walks through the two things that need attention before the - * app is usable: where exercise files live, then the prerequisite tools. Both - * panels stay available afterwards from the settings menu, so the second step - * can be left unfinished. - */ -export const Onboarding = ({ - onCompleteOnboarding, -}: { - onCompleteOnboarding: () => void; -}) => { - // The webcontentsview showing the GitMastery webpage is a native view that - // paints above this React app, so it has to stay collapsed while onboarding - // is on screen. - useEmbeddedSuppressed(true); - - const [step, setStep] = useState(0); - const [folder, setFolder] = useState(null); - const [toolsReady, setToolsReady] = useState(false); - - return ( -
    - -
    -
    - Git-Mastery logo -

    - Set up Git-Mastery -

    -
    - - {/* Two steps stretched across the card look lopsided, so the - separator is kept short and the pair centred. */} - - - {step === 0 ? ( - - ) : ( -
    - -

    - You can skip anything unfinished and finish it later in - Settings. -

    -
    - )} - -
    - {step === 0 ? ( - - - - ) : ( - - - - )} -
    -
    -
    -
    - ); -}; diff --git a/src/ui/providers/ActivityProvider.tsx b/src/ui/providers/ActivityProvider.tsx index a98ef73..20aec2b 100644 --- a/src/ui/providers/ActivityProvider.tsx +++ b/src/ui/providers/ActivityProvider.tsx @@ -11,13 +11,14 @@ // here is therefore keyed off the exercise identifier in the stream payload // rather than off the current activity. -import { type ReactNode, useEffect, useRef } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import type { Exercise } from "../../types/Exercise"; import { useElectronStream } from "../hooks/useElectronStream"; import { useLocalExercises } from "../hooks/query/useLocalExercises"; import { useToast, type ToastOptions } from "../contexts/ToastContext"; import { ActivityContext } from "../contexts/ActivityContext"; import { isHandsOnIdentifier } from "../utils/format"; +import { FirstStartModal } from "../components/Setup/FirstStartModal"; const isVerifyCommand = (cmd: string) => cmd.startsWith("verify"); @@ -35,6 +36,11 @@ export function ActivityProvider({ children }: { children: ReactNode }) { /** Loading toasts currently on screen, so settle can update them in place. */ const openActionToasts = useRef>(new Set()); + const [gate, setGate] = useState<{ + exerciseIdentifier: string; + step: FirstRunStep; + } | null>(null); + const startExercise = (exercise: Exercise) => { void window.electron.startExercise(exercise.identifier); }; @@ -82,6 +88,16 @@ export function ActivityProvider({ children }: { children: ReactNode }) { */ const onStartExerciseResult = (result: StartExerciseResult) => { const id = startToastId(result.exerciseIdentifier); + if (result.firstRunStep && result.exerciseIdentifier) { + openActionToasts.current.delete(id); + hideToast(id); + setGate({ + exerciseIdentifier: result.exerciseIdentifier, + step: result.firstRunStep, + }); + return; + } + setGate(null); if (result.ok) { openActionToasts.current.delete(id); hideToast(id); @@ -189,8 +205,10 @@ export function ActivityProvider({ children }: { children: ReactNode }) { _originalCommand: string, data: GitMasteryTaskData, ) => { + const message = data.completed?.message; settleToast(verifyToastId(data.exerciseIdentifier), { title: "Verification failed", + message: message && message.length <= 200 ? message : undefined, tone: "danger", }); }; @@ -202,6 +220,17 @@ export function ActivityProvider({ children }: { children: ReactNode }) { onFailedExit: _onExerciseVerifiedFailure, }); + const onGateResolved = (step: FirstRunStep | null) => { + if (!gate) return; + if (step === null) { + const exerciseIdentifier = gate.exerciseIdentifier; + setGate(null); + void window.electron.startExercise(exerciseIdentifier); + return; + } + setGate({ exerciseIdentifier: gate.exerciseIdentifier, step }); + }; + return ( {children} + {gate && ( + setGate(null)} + onResolved={onGateResolved} + /> + )} ); } diff --git a/types.d.ts b/types.d.ts index bde3354..8262693 100644 --- a/types.d.ts +++ b/types.d.ts @@ -27,13 +27,17 @@ interface Window { }) => void; // for configuration - setDataDirectory: (directory: string) => void; - getDataDirectory: () => Promise; selectFolder: () => Promise; - - downloadGitMasteryApp: () => Promise; - getGitMasteryVersion: () => Promise<{ version: string; latest?: string }>; - checkExerciseFolder: () => Promise; + getExerciseRoot: () => Promise<{ root: string | null }>; + setExerciseRoot: ( + directory: string, + ) => Promise<{ ok: true; root: string } | { ok: false; error: string }>; + clearExerciseRoot: () => Promise; + checkStartPrereqs: () => Promise<{ + step: FirstRunStep | null; + tools: ToolsStatus; + }>; + markStartIntroSeen: () => Promise; // for retrieving config settings of the backend (electron app) // just an array of folder names @@ -82,9 +86,6 @@ type IpcHandlerChannelMapping = { resolved: "light" | "dark"; }; - // to be saved on backend to reference whenever a new exercise needs to be downloaded - "set-data-directory": { directory: string }; - "gitmastery-task-data": { originalCommand: string; data: GitMasteryTaskData }; "start-exercise-started": StartExerciseStarted; "start-exercise-result": StartExerciseResult; @@ -107,16 +108,17 @@ type IIpcInvoke = { type IpcInvokeChannelMapping = { // config "select-folder": IIpcInvoke; - "get-data-directory": IIpcInvoke; - - // setup - "download-gitmastery-app": IIpcInvoke; - "get-gitmastery-version": IIpcInvoke< + "get-exercise-root": IIpcInvoke; + "set-exercise-root": IIpcInvoke< + { directory: string }, + { ok: true; root: string } | { ok: false; error: string } + >; + "clear-exercise-root": IIpcInvoke; + "check-start-prereqs": IIpcInvoke< null, - { version: string; latest?: string } + { step: FirstRunStep | null; tools: ToolsStatus } >; - - "check-exercise-folder": IIpcInvoke; + "mark-start-intro-seen": IIpcInvoke; "wcv-get-site-prefs": IIpcInvoke; "wcv-set-site-prefs": IIpcInvoke< @@ -133,11 +135,13 @@ type IpcInvokeChannelMapping = { >; }; -/** Where exercise files live, and whether GitMastery has created that folder. */ -type ExerciseFolderStatus = { - dataDirectory: string | null; - exercisesPath: string | null; - ready: boolean; +/** A first-Start step that is still outstanding. */ +type FirstRunStep = "intro" | "tools" | "folder"; + +type ToolsStatus = { + cli: boolean; + /** Null off Windows, where Git Bash is not required. */ + gitBash: boolean | null; }; /** CustardUI view state persisted on git-mastery.org as `git-mastery-custardUI-state`. */ @@ -172,6 +176,8 @@ type StartExerciseResult = { error?: string; downloaded?: boolean; needsRestart?: boolean; + /** Set when Start stopped because a first-run step is still outstanding. */ + firstRunStep?: FirstRunStep; }; /** Verify was clicked while the terminal was not in the exercise directory. */