diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index a012068..23cdc68 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -19,6 +19,9 @@ on: VAULT_TOKEN: required: true +permissions: + contents: write # Required to create releases and push tags + jobs: build_extension: runs-on: [self-hosted, ubuntu-22-04, regular] @@ -30,11 +33,11 @@ jobs: - name: Setup Node Environment uses: ./.github/setup-node - - name: Download fastedge-run Artifact + - name: Download debugger artifact for target platform uses: actions/download-artifact@v4 with: - name: fastedge-run-${{ inputs.os_name }}-artifact - path: fastedge-cli + name: fastedge-debugger-${{ inputs.os_target }}-artifact + path: dist/debugger - name: Extract tag version id: extract_version @@ -49,6 +52,26 @@ jobs: jq --arg version "$TAG_VERSION" '.version = $version' package.json > package.tmp.json mv package.tmp.json package.json + - name: Verify debugger artifact + run: | + echo "Verifying downloaded debugger for ${{ inputs.os_target }}..." + ls -lah dist/debugger/ + test -f dist/debugger/server.js || { echo "❌ Missing server.js"; exit 1; } + test -d dist/debugger/frontend || { echo "❌ Missing frontend"; exit 1; } + test -d dist/debugger/fastedge-cli || { echo "❌ Missing fastedge-cli"; exit 1; } + + echo "Checking CLI binary..." + ls -lh dist/debugger/fastedge-cli/ + + # Verify only the correct platform binary exists + BINARY_COUNT=$(find dist/debugger/fastedge-cli -type f \( -name 'fastedge-run*' -o -name '*.exe' \) | wc -l) + if [ "$BINARY_COUNT" -ne 1 ]; then + echo "❌ Expected exactly 1 CLI binary, found $BINARY_COUNT" + exit 1 + fi + + echo "✅ Debugger artifact verified for ${{ inputs.os_target }}" + - name: Build JavaScript extension run: | pnpm run build @@ -57,11 +80,6 @@ jobs: run: | pnpm exec vsce ls --no-dependencies - - name: Ensure extension is exectuable - if: ${{ inputs.os_target != 'win32-x64' }} - run: | - chmod +x ./fastedge-cli/fastedge-run-${{ inputs.os_target }} - - name: Package extension run: | pnpm exec vsce package --no-dependencies --target ${{ inputs.os_target }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3552f5d..d6efb40 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: # trigger manually inputs: - cli_version: - description: "FastEdge cli version" + debugger_version: + description: "FastEdge-debugger version" required: true default: "latest" tag_version: @@ -16,6 +16,9 @@ on: required: true default: "" +permissions: + contents: write # Required to create releases and push tags + jobs: check_tags: runs-on: [self-hosted, ubuntu-22-04, regular] @@ -23,30 +26,20 @@ jobs: has_release_tag: ${{ steps.determine-tag.outputs.has_tag }} steps: - - name: Checkout this repository - uses: actions/checkout@v6 - - - name: Create release tag + - name: Validate release tag id: determine-tag run: | if [ -n "${{ github.event.inputs.tag_version }}" ]; then TAG_VERSION="${{ github.event.inputs.tag_version }}" - if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then - echo "Invalid tag format: $TAG_VERSION" - exit 1 - fi - echo "Creating new release tag: $TAG_VERSION" - git tag -a "$TAG_VERSION" -m "Release $TAG_VERSION" - git push origin "$TAG_VERSION" - echo "has_tag=true" >> $GITHUB_OUTPUT else TAG_VERSION=${GITHUB_REF#refs/tags/} - if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then - echo "Invalid tag format: $TAG_VERSION" - exit 1 - fi - echo "has_tag=true" >> $GITHUB_OUTPUT fi + if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + echo "Invalid tag format: $TAG_VERSION" + exit 1 + fi + echo "has_tag=true" >> $GITHUB_OUTPUT + echo "Determined release tag: $TAG_VERSION" fossa: uses: ./.github/workflows/fossa.yaml @@ -54,16 +47,16 @@ jobs: secrets: FOSSA_PUB_API_KEY: ${{ secrets.FOSSA_PUB_API_KEY }} - download_fastedge_cli: - uses: ./.github/workflows/download-cli.yml + download_and_filter_debugger: + uses: ./.github/workflows/download-debugger.yml needs: [check_tags] if: ${{ needs.check_tags.outputs.has_release_tag == 'true' }} with: - cli_version: ${{ inputs.cli_version }} + debugger_version: ${{ inputs.debugger_version }} build_linux_extension: uses: ./.github/workflows/build-extension.yml - needs: ["fossa", "check_tags", "download_fastedge_cli"] + needs: ["fossa", "check_tags", "download_and_filter_debugger"] if: ${{ needs.check_tags.outputs.has_release_tag == 'true' }} with: os_target: linux-x64 @@ -74,7 +67,7 @@ jobs: build_darwin_extension: uses: ./.github/workflows/build-extension.yml - needs: ["fossa", "check_tags", "build_linux_extension"] + needs: ["build_linux_extension"] if: ${{ needs.check_tags.outputs.has_release_tag == 'true' }} with: os_target: darwin-arm64 @@ -85,7 +78,7 @@ jobs: build_windows_extension: uses: ./.github/workflows/build-extension.yml - needs: ["fossa", "check_tags", "build_darwin_extension"] + needs: ["build_darwin_extension"] if: ${{ needs.check_tags.outputs.has_release_tag == 'true' }} with: os_target: win32-x64 diff --git a/.github/workflows/download-cli.yml b/.github/workflows/download-cli.yml deleted file mode 100644 index 53fb480..0000000 --- a/.github/workflows/download-cli.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Get FastEdge-lib Assets - -on: - workflow_call: - inputs: - cli_version: - description: "FastEdge cli version" - required: false - type: string - default: "latest" - -jobs: - determine-cli-version: - runs-on: [self-hosted, ubuntu-22-04, regular] - outputs: - version: ${{ steps.determine-version.outputs.version }} - steps: - - name: Determine version - id: determine-version - run: | - if [ "${{ inputs.cli_version }}" == "latest" ] || [ -z "${{ inputs.cli_version }}" ]; then - echo "Fetching latest release version..." - LATEST_VERSION=$(curl -s https://api.github.com/repos/G-Core/FastEdge-lib/releases/latest | jq -r .tag_name) - echo "Latest version is $LATEST_VERSION" - echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT - else - echo "version=${{ inputs.cli_version }}" >> $GITHUB_OUTPUT - fi - - download-and-verify: - runs-on: [self-hosted, ubuntu-22-04, regular] - needs: determine-cli-version - strategy: - matrix: - include: - - target: linux-x64 - os: ubuntu-latest - file_name: x86_64-unknown-linux-gnu - file_ext: tar.gz - - - target: darwin-arm64 - os: macos-latest - file_name: aarch64-apple-darwin - file_ext: tar.gz - - - target: win32 - os: windows-latest - file_name: x86_64-pc-windows-msvc - file_ext: zip - - steps: - - name: Checkout this repository - uses: actions/checkout@v6 - - - name: Use version from determine-cli-version job - id: used-cli-version - run: | - echo "VERSION=${{ needs.determine-cli-version.outputs.version }}" >> $GITHUB_ENV - - - name: Download FastEdge-lib assets - run: | - echo "Downloading version $VERSION for ${{ matrix.os }}" - curl -L -o fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }} https://github.com/G-Core/FastEdge-lib/releases/download/$VERSION/fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }} - curl -L -o fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}.sha256 https://github.com/G-Core/FastEdge-lib/releases/download/$VERSION/fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}.sha256 - - - name: Convert Windows SHA256 to Linux format - if: ${{ matrix.os == 'windows-latest' }} - run: | - HASH=$(grep -oP '^[0-9a-f]{64}' fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}.sha256) - echo "$HASH fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}" > fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}.sha256 - - - name: Verify checksum - run: | - sha256sum -c fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }}.sha256 - - - name: Extract fastedge-run - run: | - if [[ ${{ matrix.os }} == 'windows-latest' ]]; then - unzip fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }} - else - tar -xzf fastedge-run-$VERSION-${{ matrix.file_name }}.${{ matrix.file_ext }} - fi - - - name: Rename and copy fastedge-run - run: | - if [[ ${{ matrix.os }} == 'windows-latest' ]]; then - cp fastedge-run-$VERSION-${{ matrix.file_name }}/fastedge-run.exe ./fastedge-cli/fastedge-run.exe - else - cp fastedge-run-$VERSION-${{ matrix.file_name }}/fastedge-run ./fastedge-cli/fastedge-run-${{ matrix.target }} - fi - - - name: Create a RELEASE.json file - run: | - echo "{\"fastedge_run_version\": \"$VERSION\"}" > fastedge-cli/METADATA.json - - - name: Upload fastedge-run Artifact - uses: actions/upload-artifact@v4 - with: - name: fastedge-run-${{ matrix.os }}-artifact - retention-days: 1 - path: | - fastedge-cli/ diff --git a/.github/workflows/download-debugger.yml b/.github/workflows/download-debugger.yml new file mode 100644 index 0000000..debc576 --- /dev/null +++ b/.github/workflows/download-debugger.yml @@ -0,0 +1,168 @@ +name: Download and Filter FastEdge Debugger + +on: + workflow_call: + inputs: + debugger_version: + description: "Fastedge-debugger version (e.g., v1.0.0 or 'latest')" + required: false + type: string + default: "latest" + +jobs: + # Job 1: Download debugger ONCE + download-debugger: + runs-on: [self-hosted, ubuntu-22-04, regular] + outputs: + version: ${{ steps.determine-version.outputs.version }} + steps: + - name: Determine debugger version + id: determine-version + run: | + if [ "${{ inputs.debugger_version }}" == "latest" ] || [ -z "${{ inputs.debugger_version }}" ]; then + echo "Fetching latest debugger release version..." + LATEST_VERSION=$(curl -s https://api.github.com/repos/G-Core/fastedge-test/releases/latest | jq -r .tag_name) + echo "Latest debugger version is $LATEST_VERSION" + echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT + else + echo "Using specified version ${{ inputs.debugger_version }}" + echo "version=${{ inputs.debugger_version }}" >> $GITHUB_OUTPUT + fi + + - name: Download debugger release + run: | + VERSION="${{ steps.determine-version.outputs.version }}" + echo "Downloading fastedge-debugger version $VERSION" + curl -L -o fastedge-debugger.zip \ + https://github.com/G-Core/fastedge-test/releases/download/$VERSION/fastedge-debugger.zip + curl -L -o fastedge-debugger.zip.sha256 \ + https://github.com/G-Core/fastedge-test/releases/download/$VERSION/fastedge-debugger.zip.sha256 + + - name: Verify checksum + run: | + sha256sum -c fastedge-debugger.zip.sha256 + + - name: Upload raw debugger artifact (all platforms) + uses: actions/upload-artifact@v4 + with: + name: fastedge-debugger-raw + retention-days: 1 + path: fastedge-debugger.zip + + # Job 2: Filter for each platform (parallel matrix job) + filter-for-platforms: + runs-on: [self-hosted, ubuntu-22-04, regular] + needs: download-debugger + strategy: + matrix: + include: + - os_target: linux-x64 + binary_to_keep: fastedge-run-linux-x64 + - os_target: darwin-arm64 + binary_to_keep: fastedge-run-darwin-arm64 + - os_target: win32-x64 + binary_to_keep: fastedge-run.exe + + steps: + - name: Download raw debugger artifact + uses: actions/download-artifact@v4 + with: + name: fastedge-debugger-raw + + - name: Extract debugger + run: | + echo "Extracting debugger for ${{ matrix.os_target }}..." + mkdir -p debugger-extracted + unzip -q fastedge-debugger.zip -d debugger-extracted + ls -lah debugger-extracted/ + + - name: Remove lib folder (npm package - not needed in VSCode extension) + run: | + if [ -d debugger-extracted/dist/lib ]; then + echo "Removing dist/lib/ (npm library output, not required by VSCode extension)..." + rm -rf debugger-extracted/dist/lib + echo "✅ dist/lib/ removed" + else + echo "ℹ️ dist/lib/ not present, nothing to remove" + fi + + - name: Verify extracted contents + run: | + echo "Verifying debugger structure..." + test -f debugger-extracted/dist/server.js || { echo "❌ Missing dist/server.js"; exit 1; } + test -d debugger-extracted/dist/frontend || { echo "❌ Missing dist/frontend"; exit 1; } + test -d debugger-extracted/dist/fastedge-cli || { echo "❌ Missing dist/fastedge-cli"; exit 1; } + echo "✅ Debugger structure verified" + + - name: List all CLI binaries before filtering + run: | + echo "CLI binaries in release:" + ls -lh debugger-extracted/dist/fastedge-cli/ + + - name: Filter CLI binaries for target platform + run: | + echo "Filtering for platform: ${{ matrix.os_target }}" + echo "Keeping only: ${{ matrix.binary_to_keep }}" + cd debugger-extracted/dist/fastedge-cli + + # Remove all fastedge-run binaries EXCEPT the one we need + find . -type f \( -name 'fastedge-run*' -o -name '*.exe' \) ! -name "${{ matrix.binary_to_keep }}" -delete + + # Verify only the correct binary remains + echo "Remaining files:" + ls -lh + test -f "${{ matrix.binary_to_keep }}" || { echo "❌ Binary ${{ matrix.binary_to_keep }} not found after filtering"; exit 1; } + echo "✅ Filtered to ${{ matrix.binary_to_keep }} only" + + - name: Make binary executable (Unix platforms) + if: ${{ matrix.os_target != 'win32-x64' }} + run: | + cd debugger-extracted/dist/fastedge-cli + chmod +x ${{ matrix.binary_to_keep }} + + - name: Create combined metadata file + run: | + cd debugger-extracted/dist + + # Extract fastedge_run_version from existing METADATA.json + if [ -f fastedge-cli/METADATA.json ]; then + FASTEDGE_RUN_VERSION=$(jq -r '.fastedge_run_version' fastedge-cli/METADATA.json) + echo "Found fastedge_run_version: $FASTEDGE_RUN_VERSION" + else + FASTEDGE_RUN_VERSION="unknown" + echo "⚠️ Warning: fastedge-cli/METADATA.json not found" + fi + + # Create combined metadata with all versions + cat > debugger-metadata.json < -   -   + Rust  + JavaScript  + AssemblyScript  -## ✨ New Feature 🚀 - -The latest version now supplies a new command: `FastEdge (Generate mcp.json)` - -This will prompt you for required information, followed by inserting the "FastEdge Assistant" MCP Server into your workspace. - -For more information on this MCP Server see [here](https://github.com/G-Core/FastEdge-mcp-server) +| App Type | Languages | SDK | +|----------|-----------|-----| +| **HTTP** | Rust, JavaScript | [FastEdge-sdk-rust](https://github.com/G-Core/FastEdge-sdk-rust), [FastEdge-sdk-js](https://github.com/G-Core/FastEdge-sdk-js) | +| **CDN** | Rust, AssemblyScript | [proxy-wasm-sdk-rust](https://github.com/proxy-wasm/proxy-wasm-rust-sdk), [proxy-wasm-sdk-as](https://github.com/G-Core/proxy-wasm-sdk-as) | ## How it works -Under the hood this extension compiles your code into a wasm binary using the associated language's build tools: - -The specific SDK's can be found here: - -[FastEdge-sdk-rust](https://github.com/G-Core/FastEdge-sdk-rust)
-[FastEdge-sdk-js](https://github.com/G-Core/FastEdge-sdk-js) - -Having completed compilation it then serves the running application at http://localhost:8181 +The extension compiles your code into a WASM binary using language-specific build tools, then serves it locally using a **bundled debugger** — no external tools required. -This is done using our application runner based from [FastEdge-run](https://github.com/G-Core/FastEdge-lib). +A webview panel opens inside VS Code where you can: +- Build requests (URL, method, headers, body) +- Send them to your running app +- Inspect the response (status, headers, body) +- Load and save test configurations -**Note** To view which version of the FastEdge-run your extension is using. - -1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P on macOS). -2. Type Preferences: Open Settings (UI) and select it. -3. In the Settings UI, search for FastEdge or navigate to the section for your extension. -4. You should see the cliVersion setting displayed as read-only. +Each app gets its own isolated server instance on a port in the range **5179–5188**, so you can debug multiple apps in the same workspace simultaneously. ## Prerequisites -In order for this extension to compile and run any code, you will need to have the basic compilation tools installed for your given language. +You need the build tools for your chosen language installed: -Examples: +**Rust** (HTTP or CDN apps): +```bash +rustup target add wasm32-wasip1 +``` -- Rust: `rustup target add wasm32-wasip1` -- Javascript: `npm install --save-dev @gcoredev/fastedge-sdk-js` +**JavaScript** (HTTP apps): +```bash +npm install --save-dev @gcoredev/fastedge-sdk-js +``` + +**AssemblyScript** (CDN apps): +```bash +npm install --save-dev assemblyscript @assemblyscript/wasi-shim @gcoredev/proxy-wasm-sdk-as +``` -More detail can be found in the SDK documentation above. 👆 +More detail can be found in the SDK documentation linked above. ## Installing the extension -This extension can be installed from the Visual Studio Marketplace. [FastEdge Launcher](https://marketplace.visualstudio.com/items?itemName=G-CoreLabsSA.fastedge) +This extension can be installed from the Visual Studio Marketplace: [FastEdge Launcher](https://marketplace.visualstudio.com/items?itemName=G-CoreLabsSA.fastedge) It is also possible to install from source: [Releases](https://github.com/G-Core/FastEdge-vscode/releases) ## Running your first application -Having previously installed the extension you are now able to configure and run a given project by simply pressing `F5` within VS Code.
+Press **F5** in VS Code (or Command Palette → `Debug: Start Debug`). -Alternatively you can use the Command Palette (Ctrl+Shift+P): `Debug: Start Debug` +When running for the first time, you'll need a `.vscode/launch.json`. The easiest way is to let the extension create one: -When running this for the first time in any project, you will want to create a `.vscode/launch.json` with the specific configuration settings for your application. +Command Palette (Ctrl+Shift+P) → `FastEdge: Initialize workspace (create launch.json)` -#### Example: +#### Example launch.json ```json { @@ -72,66 +72,65 @@ When running this for the first time in any project, you will want to create a ` "name": "FastEdge App Runner: Launch", "type": "fastedge", "request": "launch", - "env": { - // This is how to set environment variables for the running application - "example-name": "example-value" - } + "entrypoint": "file" } ] } ``` -The easiest way to do this is to let the extension create it for you, from the default settings provided by the extension. +The `entrypoint` field controls how the build finds your source code: -Simply run Command Palette (Ctrl+Shift+P): `Debug: FastEdge (Generate launch.json)`. +| Value | Behavior | +|-------|----------| +| `"file"` (default) | Builds the currently active editor file | +| `"package"` | Builds from the `"main"` field in `package.json` (JavaScript only) | -This will create the `.vscode` directory in your project and add a `launch.json` with the basic required configuration to run. +These are the only configuration values read from launch.json. Runtime arguments (env vars, headers, etc.) are configured separately — see [Runtime Configuration](#runtime-configuration) below. -When running `Start Debug` (F5) from vs code you should see `Serving on http://localhost:8181` in your "Debug Console" window. +## Language detection -## Commands - -This extension also provides two commands within the Command Palette (Ctrl+Shift+P) - -- Debug: FastEdge App (Current File) -- Debug: FastEdge App (Workspace) - -These behave slightly differently given the specific language and build tools. - -#### Rust - -- Debug: FastEdge App (Current File) +The extension auto-detects your project language: - This will use the current "Active text editor" location as the cwd when it attempts to `cargo build` +| Indicator | Detected as | +|-----------|-------------| +| VS Code language ID = `rust` | **Rust** | +| `asconfig.json` exists at project root | **AssemblyScript** | +| JS/TS file without `asconfig.json` | **JavaScript** | -- Debug: FastEdge App (Workspace) - - This will use VS Codes open Workspace as the cwd when it attempts to `cargo build` +## Commands -Both these commands will use the associated `cargo.toml` to configure the target build location for your binary output. +Available from the Command Palette (Ctrl+Shift+P): -#### Javascript +| Command | Description | +|---------|-------------| +| **Debug: FastEdge App (Current File)** | Build the active file and start the debugger | +| **Debug: FastEdge App (Package Entry)** | Build from `package.json` main field and start the debugger | +| **FastEdge: Initialize workspace** | Create `.vscode/launch.json` with default configuration | +| **FastEdge (Generate mcp.json)** | Add the [FastEdge MCP Server](https://github.com/G-Core/FastEdge-mcp-server) to your workspace | +| **FastEdge (Setup Codespace Secrets)** | Configure GitHub Codespaces secrets for FastEdge | -- Debug: FastEdge App (Current File) +#### Explorer context menu - This will use the current "Active text editor" as the entrypoint for `fastedge-build ` +Right-click actions in the file explorer: -- Debug: FastEdge App (Workspace) +| Command | Appears on | Description | +|---------|-----------|-------------| +| **FastEdge: Load in Debugger** | `.wasm` files | Load a pre-compiled WASM binary directly into the debugger | +| **FastEdge: Load Config in Debugger** | `*test.json` files | Load a test configuration file into the debugger | - This will use VS Codes open Workspace as the cwd, where it will then read the top level `package.json` for the "main" entrypoint. +## Runtime Configuration -As the javascript build tool `fastedge-build` requires an output location for you compiled binary. -This is set by default to your workspace `.vscode/bin/debugger.wasm` +Environment variables, secrets, request headers, and response headers are configured through **test configuration files** — not launch.json. -## Runtime Arguments +### Primary: `fastedge-config.test.json` -Providing `Environment Variables`, `Secrets`, `Request Headers` and `Response Headers` can all be achieved from editing your `.vscode/launch.json`. +The debugger UI provides built-in controls to set environment variables, secrets, and headers. These are saved to and loaded from `fastedge-config.test.json` in your app root, using native file dialogs. -Alternatively you can provide these same arguments by creating `.env` files and setting the VS Code extension to import them. +### Alternative: dotenv files -Please be aware that if you are adding **sensitive** information to these files, they should be added to your `.gitignore` file. +You can also provide runtime arguments via `.env` files that the extension auto-discovers from your app root directory. -e.g. +Please be aware that if you are adding **sensitive** information to these files, they should be added to your `.gitignore`: ``` # VSCode workspace @@ -141,6 +140,19 @@ e.g. .env .env.* +# Build artifacts +.fastedge/ ``` -For more information on how this extension locates and uses dotenv files, see [here](https://github.com/G-Core/FastEdge-vscode/blob/main/DOTENV.md) +For more information on how the extension locates and uses dotenv files, see [DOTENV.md](https://github.com/G-Core/FastEdge-vscode/blob/main/DOTENV.md). + +## Settings + +| Setting | Description | +|---------|-------------| +| `fastedge.cliVersion` | The version of the bundled debugger (read-only) | +| `fastedge.apiUrl` | Default FastEdge API URL for MCP server configuration | + +To view the bundled debugger version: +1. Open Settings (Ctrl+, or Cmd+,) +2. Search for "FastEdge" diff --git a/context/BUNDLED_DEBUGGER.md b/context/BUNDLED_DEBUGGER.md new file mode 100644 index 0000000..f86e337 --- /dev/null +++ b/context/BUNDLED_DEBUGGER.md @@ -0,0 +1,479 @@ +# Bundled Debugger Implementation + +**Last Updated**: March 12, 2026 +**Version**: 0.1.19+ +**Status**: ✅ Implemented & Packaged + +--- + +## Overview + +The FastEdge-vscode extension includes a **fully bundled fastedge-debugger** that requires zero external setup. Users can install the extension and immediately start debugging FastEdge applications without Node.js installed. + +--- + +## Architecture + +### How It Works + +``` +FastEdge Extension Install + ↓ +Extension contains bundled debugger (dist/debugger/) + ↓ +User opens a file in an app folder and runs "FastEdge: Run File" + ↓ +Extension resolves config root (nearest fastedge-config.test.json) and build root (nearest package.json / Cargo.toml) + ↓ +Extension reads /.debug-port (if present) → health-checks + ↓ +If healthy: reuse existing server If missing/stale: fork new server on next free port + ↓ +Server writes port to /.debug-port + ↓ +Per-app webview panel opens ("FastEdge Debugger — ") +``` + +Multiple apps open simultaneously each get their own server on separate ports (5179, 5180, …) and their own panel. + +### Key Components + +**1. Bundled Server** (`dist/debugger/server.js`) +- Single file: 915KB +- All dependencies bundled with esbuild +- Includes Express, ws, wasi-shim, and all runtime dependencies +- No node_modules needed + +**2. Bundled Frontend** (`dist/debugger/frontend/`) +- React-based UI +- Connects to server on port 5179 +- Real-time logs via WebSocket + +**3. Extension Integration** +- `src/utils/resolveAppRoot.ts` - Two functions: `resolveConfigRoot()` (finds `fastedge-config.test.json`) and `resolveBuildRoot()` (finds `package.json` / `Cargo.toml`). `ensureConfigFile()` creates a minimal `{}` config when none exists (third-app case). +- `DebuggerServerManager.ts` - Manages server lifecycle **per app root** — one instance per app +- `extension.ts` - Maintains `Map` and `Map` +- Uses `child_process.fork()` to spawn server +- Uses VSCode's Node.js runtime (`process.execPath`) +- No external Node.js required + +--- + +## User Experience + +### Before (Old Approach) +❌ Install extension +❌ Clone fastedge-debugger repo +❌ Run `npm install` in debugger +❌ Configure path in settings +❌ Required Node.js installed +❌ Manual setup for Rust developers + +### After (Bundled Approach) +✅ Install extension +✅ Ready to debug! + +**That's it.** No configuration, no Node.js, no manual steps. + +--- + +## Build Process + +### For Extension Developers + +The extension build automatically bundles the debugger: + +```bash +npm run package + └─→ prebuild hook runs + └─→ npm run bundle:debugger + └─→ Runs ../scripts/bundle-debugger-for-vscode.sh + └─→ Builds fastedge-debugger with build:bundle + └─→ Copies server.bundle.js to dist/debugger/ + └─→ Copies frontend to dist/debugger/ + └─→ Builds extension code + └─→ Packages into .vsix (529KB) +``` + +### Build Commands + +**Full build and package:** +```bash +npm run package +``` + +**Just bundle debugger:** +```bash +npm run bundle:debugger +``` + +**Build without bundling debugger:** +```bash +npm run build +``` + +--- + +## Technical Details + +### Server Startup + +**File**: `src/debugger/DebuggerServerManager.ts` + +```typescript +// Path to bundled server +const bundledServerPath = path.join( + this.extensionPath, + 'dist/debugger/server.js' +); + +// Fork using VSCode's Node.js +this.serverProcess = fork(bundledServerPath, [], { + cwd: path.dirname(bundledServerPath), + execPath: process.execPath, // VSCode's Node! + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + PORT: '5179', + }, +}); +``` + +### Key Design Decisions + +**1. Use fork() instead of spawn()** +- Before: `spawn('npm', ['start'])` - required npm/node in PATH +- After: `fork('server.js')` - uses VSCode's Node directly + +**2. No external path configuration** +- Before: `fastedge.debuggerPath` setting for external debugger +- After: Always use bundled debugger at `dist/debugger/` + +**3. All dependencies bundled** +- Before: Copied node_modules (vsce packaging issues) +- After: esbuild bundles everything into single server.js + +**4. One server per app root (March 2026)** +- Before: Single global server on port 5179 shared by all apps +- After: One server per app folder; port file at `/.debug-port` for discovery +- Isolation boundary: nearest ancestor dir containing `fastedge-config.test.json` (`configRoot`) +- If no config file exists, a minimal `{}` one is auto-created **co-located with the active file** on first run — making that directory its own configRoot +- Closing the debug panel stops that app's server and deletes its port file + +**8. Config root vs build root split (March 2026)** +- Before: Single `resolveAppRoot()` used for both build CWD and per-app identity — `fastedge-config.test.json` could override the build root incorrectly +- After: `resolveConfigRoot()` finds the per-app anchor; `resolveBuildRoot()` finds where build commands run +- Example — workspace-1: `first-app/fastedge-config.test.json` → configRoot = `first-app/`; `package.json` at workspace root → buildRoot = `workspace-1/` +- WASM output (`.fastedge/bin/debugger.wasm`) is written to `configRoot` so the server can find it via `WORKSPACE_PATH` + +**6. Wait for WebSocket client before loading WASM (March 2026)** +- Before: extension called `POST /api/load` immediately after creating the webview panel → `wasm_loaded` event fired before UI WebSocket connected → UI missed it, stayed blank +- After: extension polls `GET /api/client-count` until count > 0 before loading +- See [WASM Loading Flow](#wasm-loading-flow) + +**7. Path-based loading in extension (March 2026)** +- Before: `loadWasm()` read the WASM file into a buffer and sent as base64 → server had no filename, used placeholder `"binary.wasm"` → `wasmPath` in UI store was wrong +- After: sends `wasmPath` directly; server reads the file and returns `resolvedPath` in the `wasm_loaded` event + +**5. Build runs from app root (March 2026)** +- Before: `jsBuild` and `rustBuild` used `workspaceFolders[0]` as CWD → broke multi-root workspaces +- After: `resolveAppRoot(activeFilePath)` derives the correct CWD for both build and output paths + +--- + +## Config File Load/Save — Native VSCode Dialogs + +### Why browser file APIs fail in the debugger iframe + +The debugger UI runs inside an ` + + + +`; + } + + /** + * Send a config file's content to the debugger UI. + * Posts a filePickerResult message, which ConfigButtons already handles. + * Waits for the React app to connect via WebSocket before posting. + */ + async sendConfig(content: string, fileName: string): Promise { + await this.waitForWebSocketClient(); + if (!this.panel) { + throw new Error("Debugger panel was closed before the config could be sent."); + } + this.panel.webview.postMessage({ command: "filePickerResult", content, fileName }); + } + + /** + * Close the debugger webview + */ + close(): void { + if (this.panel) { + this.panel.dispose(); + this.panel = null; + } + } + + /** + * Check if the debugger webview is visible + */ + isVisible(): boolean { + return this.panel !== null && this.panel.visible; + } +} diff --git a/src/debugger/index.ts b/src/debugger/index.ts new file mode 100644 index 0000000..c78ba9d --- /dev/null +++ b/src/debugger/index.ts @@ -0,0 +1,9 @@ +/** + * Debugger integration module + * + * This module provides integration with the fastedge-debugger server, + * allowing developers to test FastEdge applications locally before deployment. + */ + +export { DebuggerServerManager } from "./DebuggerServerManager"; +export { DebuggerWebviewProvider } from "./DebuggerWebviewProvider"; diff --git a/src/dotenv/index.ts b/src/dotenv/index.ts index cfd9a43..6302a67 100644 --- a/src/dotenv/index.ts +++ b/src/dotenv/index.ts @@ -6,7 +6,7 @@ import { DebugContext } from "../types"; function findNearestDotenvFolder( stopDir: string, - startDir: string + startDir: string, ): string | null { // Walks up the tree from activeFile location to find the nearest .env file // stops when it reaches the Workspace root. i.e. --dotenv will be set as "false" @@ -39,14 +39,17 @@ function validateDotenvPath(dotenvPath: string): string | null { } function findDotenvLocation( - debugContext: DebugContext = "file" + debugContext: DebugContext = "file", ): string | null { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const activeFile = debugContext === "workspace" - ? vscode.workspace?.workspaceFolders?.[0].uri.fsPath + - path.sep + - "index.js" // Does not matter what this filename is.. used solely for the directory structure. + ? workspaceFolder + ? path.join( + workspaceFolder.uri.fsPath, + "index.js", // Does not matter what this filename is.. used solely for the directory structure. + ) + : undefined : vscode.window.activeTextEditor?.document.uri.fsPath; if (!activeFile || !workspaceFolder) { @@ -54,7 +57,7 @@ function findDotenvLocation( } const dotEnvLocation = findNearestDotenvFolder( workspaceFolder.uri.fsPath, - path.dirname(activeFile) + path.dirname(activeFile), ); return dotEnvLocation; } diff --git a/src/extension.ts b/src/extension.ts index df3fb3e..09ff07f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,22 +2,56 @@ import * as vscode from "vscode"; import path from "path"; import { readFileSync } from "fs"; -import { FastEdgeDebugAdapterDescriptorFactory } from "./FastEdgeDebugAdapterDescriptorFactory"; -import { BinaryDebugConfigurationProvider } from "./BinaryDebugConfigurationProvider"; import { - createLaunchJson, createMCPJson, + initWorkspace, setupCodespaceSecret, runFile, runWorkspace, + loadWasmInDebugger, + loadConfigInDebugger, + initializeDebuggerComponents, } from "./commands"; import { initializeTriggerFileHandler } from "./autorun/triggerFileHandler"; +import { + DebuggerServerManager, + DebuggerWebviewProvider, +} from "./debugger"; + +// Per-app-root instances — keyed by resolved app root path +const serverManagers = new Map(); +const webviewProviders = new Map(); + +let extensionContext: vscode.ExtensionContext | null = null; + +function getOrCreateForAppRoot(appRoot: string): { + manager: DebuggerServerManager; + provider: DebuggerWebviewProvider; +} { + if (!extensionContext) { + throw new Error("Extension not activated"); + } + + if (!serverManagers.has(appRoot)) { + const manager = new DebuggerServerManager(extensionContext.extensionPath, appRoot); + const provider = new DebuggerWebviewProvider(extensionContext, manager); + serverManagers.set(appRoot, manager); + webviewProviders.set(appRoot, provider); + } + + return { + manager: serverManagers.get(appRoot)!, + provider: webviewProviders.get(appRoot)!, + }; +} export function activate(context: vscode.ExtensionContext) { - // Read the cliVersion from METADATA.json + extensionContext = context; + + // Read the cliVersion from METADATA.json (bundled with debugger) const metadataJsonPath = path.join( context.extensionPath, - "fastedge-cli/METADATA.json", + "dist/debugger/fastedge-cli/METADATA.json", ); const metadataJson = JSON.parse(readFileSync(metadataJsonPath, "utf8")); const cliVersion = metadataJson.fastedge_run_version || "unknown"; @@ -34,26 +68,54 @@ export function activate(context: vscode.ExtensionContext) { // Initialize trigger file handler for auto-running commands initializeTriggerFileHandler(context); + // Wire up the per-app factory for runFile/runWorkspace + initializeDebuggerComponents(getOrCreateForAppRoot); + + // Register debug configuration provider so F5 works + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider("fastedge", { + resolveDebugConfiguration( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + token?: vscode.CancellationToken + ): vscode.ProviderResult { + // When F5 is pressed, trigger our build and debug workflow + const debugContext = config.debugContext || config.entrypoint || "file"; + if (debugContext === "workspace" || debugContext === "package") { + runWorkspace(); + } else { + runFile(); + } + // Return undefined to cancel the default debug session + // since we're handling it ourselves + return undefined; + }, + }) + ); + context.subscriptions.push( vscode.commands.registerCommand("fastedge.run-file", runFile), vscode.commands.registerCommand("fastedge.run-workspace", runWorkspace), - vscode.commands.registerCommand( - "fastedge.generate-launch-json", - createLaunchJson, - ), + vscode.commands.registerCommand("fastedge.init-workspace", initWorkspace), vscode.commands.registerCommand("fastedge.generate-mcp-json", () => createMCPJson(context), ), vscode.commands.registerCommand("fastedge.setup-codespace-secret", () => setupCodespaceSecret(context), ), - vscode.debug.registerDebugAdapterDescriptorFactory( - "fastedge", - new FastEdgeDebugAdapterDescriptorFactory(), + vscode.commands.registerCommand("fastedge.debug-load-wasm", (uri?: vscode.Uri) => + loadWasmInDebugger(uri), ), - vscode.debug.registerDebugConfigurationProvider( - "fastedge", - new BinaryDebugConfigurationProvider(context), + vscode.commands.registerCommand("fastedge.debug-load-config", (uri?: vscode.Uri) => + loadConfigInDebugger(uri), ), ); } + +export function deactivate() { + // Stop all per-app debugger servers on extension deactivation + const stops = Array.from(serverManagers.values()).map((m) => + m.stop().catch(console.error) + ); + return Promise.all(stops); +} diff --git a/src/types.ts b/src/types.ts index 7e2da60..ad90660 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -type ExtLanguage = "javascript" | "rust"; +type ExtLanguage = "javascript" | "rust" | "assemblyscript"; type DebugContext = "file" | "workspace"; type BinaryInfo = { @@ -13,26 +13,10 @@ interface MCPConfiguration { servers: Record; } -interface LaunchConfiguration { - cliPath: string; - entrypoint?: string; - binary?: BinaryInfo; - port?: number; - dotenv?: boolean | string; - geoIpHeaders?: boolean; - headers?: Record; - env?: Record; - secrets?: Record; - responseHeaders?: Record; - memoryLimit?: number; - traceLogging?: boolean; -} - export { BinaryInfo, DebugContext, ExtLanguage, - LaunchConfiguration, LogToDebugConsole, MCPConfiguration, }; diff --git a/src/utils/resolveAppRoot.test.ts b/src/utils/resolveAppRoot.test.ts new file mode 100644 index 0000000..3521e8e --- /dev/null +++ b/src/utils/resolveAppRoot.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resolveConfigRoot, resolveBuildRoot, ensureConfigFile } from "./resolveAppRoot"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mkTmp(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "fastedge-test-")); +} + +function touch(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "", "utf8"); +} + +// --------------------------------------------------------------------------- +// Fixtures +// +// workspace-1: fastedge-config.test.json in subdir, package.json at root +// +// tmp/ +// ├── first-app/ +// │ ├── index.js +// │ └── fastedge-config.test.json +// ├── second-app/ +// │ ├── index.js +// │ └── fastedge-config.test.json +// └── package.json +// +// workspace-2/first-app: config and package.json at same level +// +// tmp/ +// ├── src/index.js +// ├── package.json +// └── fastedge-config.test.json +// +// workspace-2/second-app: config inside src/, package.json one level up +// +// tmp/ +// ├── src/ +// │ ├── index.js +// │ └── fastedge-config.test.json +// └── package.json +// +// workspace-2/third-app: no config file, package.json at root (auto-create case) +// +// tmp/ +// ├── src/index.js +// └── package.json +// --------------------------------------------------------------------------- + +let tmp: string; + +beforeEach(() => { + tmp = mkTmp(); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// resolveConfigRoot +// --------------------------------------------------------------------------- + +describe("resolveConfigRoot", () => { + it("returns the dir containing fastedge-config.test.json (workspace-1 first-app)", () => { + const configDir = path.join(tmp, "first-app"); + touch(path.join(configDir, "fastedge-config.test.json")); + touch(path.join(configDir, "index.js")); + touch(path.join(tmp, "package.json")); + + const result = resolveConfigRoot(path.join(configDir, "index.js")); + expect(result).toBe(configDir); + }); + + it("returns the dir containing fastedge-config.test.json (workspace-2/second-app — config in src/)", () => { + const srcDir = path.join(tmp, "src"); + touch(path.join(srcDir, "index.js")); + touch(path.join(srcDir, "fastedge-config.test.json")); + touch(path.join(tmp, "package.json")); + + const result = resolveConfigRoot(path.join(srcDir, "index.js")); + expect(result).toBe(srcDir); + }); + + it("returns the dir when config and package.json are at the same level (workspace-2/first-app)", () => { + touch(path.join(tmp, "src", "index.js")); + touch(path.join(tmp, "package.json")); + touch(path.join(tmp, "fastedge-config.test.json")); + + const result = resolveConfigRoot(path.join(tmp, "src", "index.js")); + expect(result).toBe(tmp); + }); + + it("returns null when no fastedge-config.test.json exists (third-app)", () => { + touch(path.join(tmp, "src", "index.js")); + touch(path.join(tmp, "package.json")); + + const result = resolveConfigRoot(path.join(tmp, "src", "index.js")); + expect(result).toBeNull(); + }); + + it("returns null when startPath does not exist", () => { + const result = resolveConfigRoot(path.join(tmp, "nonexistent", "file.js")); + expect(result).toBeNull(); + }); + + it("works when startPath is a directory", () => { + touch(path.join(tmp, "fastedge-config.test.json")); + const result = resolveConfigRoot(tmp); + expect(result).toBe(tmp); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBuildRoot +// --------------------------------------------------------------------------- + +describe("resolveBuildRoot", () => { + it("returns workspace root package.json dir (workspace-1: config in subdir)", () => { + const appDir = path.join(tmp, "first-app"); + touch(path.join(appDir, "index.js")); + touch(path.join(appDir, "fastedge-config.test.json")); + touch(path.join(tmp, "package.json")); + + const result = resolveBuildRoot(path.join(appDir, "index.js")); + expect(result).toBe(tmp); + }); + + it("returns the nearest package.json dir (workspace-2/first-app: same level as config)", () => { + touch(path.join(tmp, "src", "index.js")); + touch(path.join(tmp, "package.json")); + touch(path.join(tmp, "fastedge-config.test.json")); + + const result = resolveBuildRoot(path.join(tmp, "src", "index.js")); + expect(result).toBe(tmp); + }); + + it("returns the correct build root when config is deeper than package.json (workspace-2/second-app)", () => { + const srcDir = path.join(tmp, "src"); + touch(path.join(srcDir, "index.js")); + touch(path.join(srcDir, "fastedge-config.test.json")); + touch(path.join(tmp, "package.json")); + + const result = resolveBuildRoot(path.join(srcDir, "index.js")); + expect(result).toBe(tmp); + }); + + it("returns the build root for third-app (no config file)", () => { + touch(path.join(tmp, "src", "index.js")); + touch(path.join(tmp, "package.json")); + + const result = resolveBuildRoot(path.join(tmp, "src", "index.js")); + expect(result).toBe(tmp); + }); + + it("finds Cargo.toml as build root for Rust apps", () => { + touch(path.join(tmp, "src", "main.rs")); + touch(path.join(tmp, "Cargo.toml")); + + const result = resolveBuildRoot(path.join(tmp, "src", "main.rs")); + expect(result).toBe(tmp); + }); + + it("returns null when neither package.json nor Cargo.toml exists", () => { + touch(path.join(tmp, "src", "index.js")); + + const result = resolveBuildRoot(path.join(tmp, "src", "index.js")); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// ensureConfigFile +// --------------------------------------------------------------------------- + +describe("ensureConfigFile", () => { + it("creates fastedge-config.test.json with {} when it does not exist", () => { + ensureConfigFile(tmp); + + const filePath = path.join(tmp, "fastedge-config.test.json"); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, "utf8")).toBe("{}"); + }); + + it("does not overwrite an existing fastedge-config.test.json", () => { + const filePath = path.join(tmp, "fastedge-config.test.json"); + const existing = '{"type":"cdn"}'; + fs.writeFileSync(filePath, existing, "utf8"); + + ensureConfigFile(tmp); + + expect(fs.readFileSync(filePath, "utf8")).toBe(existing); + }); + + it("after ensureConfigFile at activeFile dir, resolveConfigRoot finds that dir", () => { + // Simulates the third-app flow: no config found, so we create one + // co-located with the active file (not at the build root). + const srcDir = path.join(tmp, "src"); + touch(path.join(srcDir, "index.js")); + touch(path.join(tmp, "package.json")); // build root is tmp, not srcDir + + // Caller places config next to the active file + ensureConfigFile(srcDir); + + const result = resolveConfigRoot(path.join(srcDir, "index.js")); + expect(result).toBe(srcDir); // srcDir, not tmp + }); +}); + +// --------------------------------------------------------------------------- +// Config root vs build root split — workspace-1 scenario end-to-end +// --------------------------------------------------------------------------- + +describe("config root vs build root split (workspace-1)", () => { + it("configRoot is the app subdir, buildRoot is the workspace root", () => { + const appDir = path.join(tmp, "first-app"); + touch(path.join(appDir, "index.js")); + touch(path.join(appDir, "fastedge-config.test.json")); + touch(path.join(tmp, "package.json")); + + const activeFile = path.join(appDir, "index.js"); + expect(resolveConfigRoot(activeFile)).toBe(appDir); + expect(resolveBuildRoot(activeFile)).toBe(tmp); + }); + + it("two apps in same workspace get different configRoots but same buildRoot", () => { + const firstApp = path.join(tmp, "first-app"); + const secondApp = path.join(tmp, "second-app"); + touch(path.join(firstApp, "index.js")); + touch(path.join(firstApp, "fastedge-config.test.json")); + touch(path.join(secondApp, "index.js")); + touch(path.join(secondApp, "fastedge-config.test.json")); + touch(path.join(tmp, "package.json")); + + expect(resolveConfigRoot(path.join(firstApp, "index.js"))).toBe(firstApp); + expect(resolveConfigRoot(path.join(secondApp, "index.js"))).toBe(secondApp); + expect(resolveBuildRoot(path.join(firstApp, "index.js"))).toBe(tmp); + expect(resolveBuildRoot(path.join(secondApp, "index.js"))).toBe(tmp); + }); +}); diff --git a/src/utils/resolveAppRoot.ts b/src/utils/resolveAppRoot.ts new file mode 100644 index 0000000..afd5305 --- /dev/null +++ b/src/utils/resolveAppRoot.ts @@ -0,0 +1,70 @@ +import * as fs from "fs"; +import * as path from "path"; + +function startDir(startPath: string): string { + try { + return fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath); + } catch { + return path.dirname(startPath); + } +} + +/** + * Walk up from a file (or directory) path to find the per-app config root. + * + * Returns the first directory containing `fastedge-config.test.json`, or null + * if none is found. This is the anchor for per-app identity (.debug-port sidecar, + * WORKSPACE_PATH passed to the debugger server). + */ +export function resolveConfigRoot(startPath: string): string | null { + let dir = startDir(startPath); + + while (true) { + if (fs.existsSync(path.join(dir, "fastedge-config.test.json"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Walk up from a file (or directory) path to find the build root. + * + * Returns the first directory containing `package.json` or `Cargo.toml`. + * This is where build commands (e.g. fastedge-build) must be run. + * Returns null only if no build manifest is found up to the filesystem root. + */ +export function resolveBuildRoot(startPath: string): string | null { + let dir = startDir(startPath); + + while (true) { + if ( + fs.existsSync(path.join(dir, "package.json")) || + fs.existsSync(path.join(dir, "Cargo.toml")) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Ensure a minimal `fastedge-config.test.json` exists in the given directory. + * + * Used when no config file is found during root resolution (the "third-app" case — + * a project with only a package.json / Cargo.toml and no per-app config yet). + * Writing `{}` creates the anchor that isolates this app's debug port from others + * in the same workspace without making any assumptions about app configuration. + * + * No-op if the file already exists. + */ +export function ensureConfigFile(dir: string): void { + const configPath = path.join(dir, "fastedge-config.test.json"); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, "{}", "utf8"); + } +}