diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 2e49b9f7ad7..029fd4051fe 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -5,17 +5,64 @@ on: tags: - "cli-v*" +# Restrict the default GITHUB_TOKEN to the minimum needed for this workflow: +# - contents: write is required to create the GitHub Release and upload assets. +# We deliberately do NOT request packages, id-token, or other write scopes. permissions: contents: write jobs: - release: - name: Build and publish CLI + build: + name: Build CLI artifact runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Verify tag points to current main + env: + TAG_NAME: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Only release tags whose commit is the tip of the default branch. + # This blocks accidental releases from arbitrary branches or + # rewritten history. + default_branch="${GITHUB_BASE_REF:-main}" + tag_sha="$(git rev-parse --verify "refs/tags/${TAG_NAME}^{commit}")" + tip_sha="$(git rev-parse --verify "origin/${default_branch}")" + if [[ "$tag_sha" != "$tip_sha" ]]; then + echo "::error::Tag ${TAG_NAME} points to ${tag_sha} but origin/${default_branch} is at ${tip_sha}." >&2 + echo "::error::Releases must be tagged at the current tip of ${default_branch}." >&2 + exit 1 + fi + echo "Tag ${TAG_NAME} matches origin/${default_branch} (${tag_sha})." + + - name: Extract version from tag + id: version + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version="${tag#cli-v}" + if [[ -z "$version" || "$version" == "$tag" ]]; then + echo "::error::Tag '$tag' must follow the cli-v pattern." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Sync package.json version with tag + working-directory: extensions/cli + run: | + set -euo pipefail + current="$(node -p "require('./package.json').version")" + if [[ "$current" != "${{ steps.version.outputs.version }}" ]]; then + echo "::error::extensions/cli/package.json version is '$current' but tag is '${{ steps.version.outputs.version }}'." >&2 + echo "::error::Update package.json before tagging, or remove this guard." >&2 + exit 1 + fi - name: Set up Node.js uses: actions/setup-node@v7 @@ -24,38 +71,121 @@ jobs: cache: npm cache-dependency-path: extensions/cli/package-lock.json - - name: Build local CLI dependencies and CLI bundle + - name: Build local CLI dependencies working-directory: extensions/cli - run: | - npm run build:local-deps - npm run build + run: npm run build:local-deps + + - name: Build CLI bundle + working-directory: extensions/cli + run: npm run build - - name: Verify release entrypoint + - name: Verify release entrypoint exists working-directory: extensions/cli - run: node dist/cn.js --help >/tmp/cn-help.txt && grep -qi "continue" /tmp/cn-help.txt + run: | + set -euo pipefail + test -f dist/cn.js || { echo "dist/cn.js missing" >&2; exit 1; } + test -f dist/index.js || { echo "dist/index.js missing" >&2; exit 1; } + test -f dist/xhr-sync-worker.js || { echo "dist/xhr-sync-worker.js missing" >&2; exit 1; } - name: Package CLI run: | + set -euo pipefail rm -rf release mkdir -p release/cn cp extensions/cli/dist/index.js release/cn/index.js cp extensions/cli/dist/cn.js release/cn/cn.js cp extensions/cli/dist/xhr-sync-worker.js release/cn/xhr-sync-worker.js - printf '%s\n' '{"type":"module"}' > release/cn/package.json - ( - cd release/cn - node cn.js --help >/tmp/packaged-cn-help.txt - grep -qi "continue" /tmp/packaged-cn-help.txt - ) + # The artifact must advertise ESM so `node cn.js` loads correctly, + # and must carry the version so `cn --version` reports the same + # semver that the GitHub Release was tagged with. + version="${{ steps.version.outputs.version }}" + node -e ' + const fs = require("fs"); + const pkg = JSON.parse(fs.readFileSync("extensions/cli/package.json", "utf8")); + fs.writeFileSync( + "release/cn/package.json", + JSON.stringify( + { name: pkg.name, version: pkg.version, type: "module" }, + null, + 2, + ) + "\n", + ); + ' tar -C release -czf release/cn-node.tar.gz cn cp extensions/cli/scripts/install-fork.sh release/install.sh + sha256sum release/cn-node.tar.gz | awk '{print $1 " cn-node.tar.gz"}' > release/cn-node.tar.gz.sha256 + + - name: Smoke-test packaged artifact (Linux) + working-directory: release/cn + run: | + set -euo pipefail + node cn.js --version + node cn.js --help > /tmp/cn-help.txt + grep -qi "continue" /tmp/cn-help.txt + + - name: Upload release artifact + uses: actions/upload-artifact@v7 + with: + name: cli-release-${{ github.ref_name }} + path: release/ + + test-artifact: + name: Smoke-test packaged artifact on ${{ matrix.os }} + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Download artifact + uses: actions/download-artifact@v7 + with: + name: cli-release-${{ github.ref_name }} + path: artifact + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Run packaged cn entrypoint + working-directory: artifact/cn + run: | + set -euo pipefail + test -f cn.js + test -f index.js + test -f package.json + # --version and --help must succeed on the exact files we ship. + node cn.js --version + node cn.js --help > /tmp/cn-help.txt + grep -qi "continue" /tmp/cn-help.txt + + publish: + name: Publish GitHub release + needs: [build, test-artifact] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download artifact + uses: actions/download-artifact@v7 + with: + name: cli-release-${{ github.ref_name }} + path: release - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail gh release create "${GITHUB_REF_NAME}" \ release/cn-node.tar.gz \ + release/cn-node.tar.gz.sha256 \ release/install.sh \ --title "Continue CLI ${GITHUB_REF_NAME#cli-v}" \ --generate-notes diff --git a/extensions/cli/CHANGELOG.md b/extensions/cli/CHANGELOG.md index dd478426efa..642dc70565e 100644 --- a/extensions/cli/CHANGELOG.md +++ b/extensions/cli/CHANGELOG.md @@ -8,6 +8,14 @@ ### Bug Fixes - add one column of horizontal padding around the TUI chat and input area so message bullets, assistant responses, tool output, and status content share the same left edge; Ink now wraps long text within the padded area ([extensions/cli/src/ui/TUIChat.tsx](extensions/cli/src/ui/TUIChat.tsx)) +- break the esbuild/ESM import cycle between `compaction.ts` and `stream/streamChatResponse.ts` by extracting `pruneLastMessage` to `util/chatHistoryPrune.ts` and routing `compactChatHistory` / `subagent/executor.ts` through a late-bound `streamChatResponseRef` so the bundled CLI no longer crashes with `SyntaxError: Unexpected reserved word` at module load ([extensions/cli/src/compaction.ts](extensions/cli/src/compaction.ts), [extensions/cli/src/util/chatHistoryPrune.ts](extensions/cli/src/util/chatHistoryPrune.ts), [extensions/cli/src/stream/streamChatResponse.lateRef.ts](extensions/cli/src/stream/streamChatResponse.lateRef.ts)) +- teach `getVersion` to read the `package.json` shipped next to `cn.js` in the release artifact so `cn --version` reports the same semver the GitHub Release was tagged with, instead of silently falling back to `unknown` ([extensions/cli/src/version.ts](extensions/cli/src/version.ts)) + +### Hardening + +- the GitHub release workflow now refuses to publish a `cli-v*` tag whose commit is not the tip of `main`, fails the build if the tag and `extensions/cli/package.json` version disagree, writes a SHA-256 checksum file for the tarball, ships a version-stamped `package.json` inside the artifact, and runs the packaged-artifact smoke test on both `ubuntu-latest` and `macos-latest` before any release is created ([.github/workflows/release-cli.yml](.github/workflows/release-cli.yml)) +- the curl installer verifies a SHA-256 checksum published alongside the tarball and refuses to install on mismatch, refuses to extract tar entries that escape the destination directory, resolves symlinks in the wrapper so the symlinked `cn` always invokes the real `cn.js`, and is fully idempotent ([extensions/cli/scripts/install-fork.sh](extensions/cli/scripts/install-fork.sh)) +- the onboarding provider picker now asks Azure users for the deployment name and writes the `deployment` / `apiType` / `apiVersion` triple required by the current Continue Azure docs ([extensions/cli/src/onboardingProviders.ts](extensions/cli/src/onboardingProviders.ts), [extensions/cli/src/ui/components/ProviderPicker.tsx](extensions/cli/src/ui/components/ProviderPicker.tsx)) ## [1.4.2](https://github.com/continuedev/cli/compare/v1.4.1...v1.4.2) (2025-07-17) diff --git a/extensions/cli/README.md b/extensions/cli/README.md index 77772d90ad4..389d9178a3f 100644 --- a/extensions/cli/README.md +++ b/extensions/cli/README.md @@ -20,6 +20,10 @@ curl -fsSL https://github.com/continued-agent/continued/releases/latest/download This downloads the prebuilt CLI from the latest GitHub Release. It does not clone the repository or build the CLI locally. The installer places `cn` in `~/.local/bin` and the release files in `~/.local/share/continue-cli`. +The installer downloads `cn-node.tar.gz` and a sibling `cn-node.tar.gz.sha256` checksum file from the GitHub Release, recomputes the SHA-256 of the archive, and aborts if the two don't match. It also refuses to extract any tar entry that would escape the destination directory. + +If you prefer to pin a specific version, set `CONTINUE_CLI_VERSION` before running the script (e.g. `CONTINUE_CLI_VERSION=cli-v0.1.0`). The wrapper script installs into `~/.local/share/continue-cli/current`; if `~/.local/bin` is not already on your `PATH`, the script detects your login shell and prints the exact line to add. + **Windows (PowerShell):** ```powershell @@ -106,8 +110,30 @@ cn ls --json - `CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE`: Disable adding the Continue commit signature to generated commit messages - `FORCE_NO_TTY`: Force TTY-less mode, prevents stdin reading (useful for testing and automation) +- `CONTINUE_CLI_VERSION`: Pin the install script to a specific release tag (e.g. `cli-v0.1.0`); defaults to `latest` +- `CONTINUE_CLI_INSTALL_DIR`: Override the install root (default `~/.local/share/continue-cli`) +- `CONTINUE_CLI_BIN_DIR`: Override the symlink directory (default `~/.local/bin`) - Provider-specific API key variables are referenced from `config.yaml` as `${{ secrets.NAME }}`; common examples are `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `AZURE_API_KEY`, `BEDROCK_API_KEY`, `NVIDIA_API_KEY`, and `HF_TOKEN` +## Updating and Uninstalling + +The installer is idempotent: re-running it replaces the current install in place and refreshes the `~/.local/bin/cn` symlink. To pin a specific version while updating, set `CONTINUE_CLI_VERSION` as shown above. + +To uninstall, remove the symlink and the install root: + +```bash +rm -f ~/.local/bin/cn +rm -rf ~/.local/share/continue-cli +``` + +To verify a downloaded archive manually before installing: + +```bash +curl -fsSL https://github.com/continued-agent/continued/releases/latest/download/cn-node.tar.gz -o cn-node.tar.gz +curl -fsSL https://github.com/continued-agent/continued/releases/latest/download/cn-node.tar.gz.sha256 -o cn-node.tar.gz.sha256 +sha256sum -c cn-node.tar.gz.sha256 +``` + ## Commands - `cn`: Start an interactive chat session diff --git a/extensions/cli/scripts/install-fork.sh b/extensions/cli/scripts/install-fork.sh index 51cd4d98257..258153b7d7c 100644 --- a/extensions/cli/scripts/install-fork.sh +++ b/extensions/cli/scripts/install-fork.sh @@ -1,8 +1,25 @@ #!/usr/bin/env bash set -euo pipefail +# Continue CLI installer (continued-agent/continued releases) +# +# curl -fsSL https://github.com/continued-agent/continued/releases/latest/download/install.sh | bash +# +# Installs `cn` under ~/.local/share/continue-cli/current and symlinks +# ~/.local/bin/cn to the wrapper. Verifies a SHA-256 checksum published +# alongside the archive and refuses to install if the archive is missing, +# invalid, or appears tampered with. + REPO="continued-agent/continued" -ASSET_URL="https://github.com/${REPO}/releases/latest/download/cn-node.tar.gz" +VERSION="${CONTINUE_CLI_VERSION:-latest}" +if [[ "$VERSION" == "latest" ]]; then + BASE_URL="https://github.com/${REPO}/releases/latest/download" +else + BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" +fi +ARCHIVE_URL="${BASE_URL}/cn-node.tar.gz" +CHECKSUM_URL="${BASE_URL}/cn-node.tar.gz.sha256" + INSTALL_ROOT="${CONTINUE_CLI_INSTALL_DIR:-$HOME/.local/share/continue-cli}" BIN_DIR="${CONTINUE_CLI_BIN_DIR:-$HOME/.local/bin}" TMP_DIR="$(mktemp -d)" @@ -24,30 +41,122 @@ fi command -v curl >/dev/null 2>&1 || error "curl is required." command -v tar >/dev/null 2>&1 || error "tar is required." command -v node >/dev/null 2>&1 || error "Node.js >= 18 is required. Install Node.js first." +command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 \ + || error "sha256sum or shasum is required to verify the archive." node_major="$(node -p 'process.versions.node.split(".")[0]')" if (( node_major < 18 )); then error "Node.js >= 18 is required; found $(node --version)." fi +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +verify_checksum() { + local archive="$1" checksum_file="$2" + local expected actual + expected="$(awk 'NF {print $1; exit}' "$checksum_file")" + [[ -n "$expected" ]] || error "Checksum file is empty or malformed: $checksum_file" + actual="$(sha256_of "$archive")" + if [[ "$expected" != "$actual" ]]; then + error "Checksum mismatch. + Expected: $expected + Actual: $actual + The archive may be corrupted or tampered with. Aborting." + fi +} + +# Refuse to follow entries that would write outside the destination. +safe_extract() { + local archive="$1" dest="$2" + tar -tzf "$archive" \ + | while IFS= read -r entry; do + case "$entry" in + */../*|../*|*/..|..) + error "Archive contains a forbidden entry: $entry" + ;; + esac + done + # --no-same-owner: don't try to chown to the archived uid/gid. + # --no-same-permissions: avoid escalating to a+rw bits; we'll set perms explicitly. + tar -xzf "$archive" -C "$dest" --no-same-owner +} + +# Detect the user's login shell so we can give an accurate PATH hint. +detect_shell() { + local shell_name + shell_name="${SHELL:-}" + if [[ -z "$shell_name" && -f /etc/passwd ]]; then + shell_name="$(getent passwd "$(id -u 2>/dev/null || echo "$USER")" 2>/dev/null | awk -F: '{print $NF; exit}')" + fi + if [[ -z "$shell_name" ]]; then + shell_name="/bin/sh" + fi + basename -- "$shell_name" +} + +path_hint() { + local shell_name="$1" bin="$2" + case "$shell_name" in + fish) + printf 'set -gx PATH "%s" $PATH' "$bin" + ;; + zsh) + printf 'export PATH="%s:$PATH"' "$bin" + ;; + bash|sh|ksh|*) + printf 'export PATH="%s:$PATH"' "$bin" + ;; + esac +} + +printf 'Downloading Continue CLI from %s\n' "$ARCHIVE_URL" +curl -fsSL "$ARCHIVE_URL" -o "$TMP_DIR/cn-node.tar.gz" +curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/cn-node.tar.gz.sha256" + +verify_checksum "$TMP_DIR/cn-node.tar.gz" "$TMP_DIR/cn-node.tar.gz.sha256" + mkdir -p "$INSTALL_ROOT" "$BIN_DIR" -printf 'Downloading Continue CLI from %s\n' "$ASSET_URL" -curl -fsSL "$ASSET_URL" -o "$TMP_DIR/cn-node.tar.gz" +safe_extract "$TMP_DIR/cn-node.tar.gz" "$TMP_DIR" + +[[ -d "$TMP_DIR/cn" ]] || error "Archive did not contain the expected 'cn/' directory." +[[ -f "$TMP_DIR/cn/cn.js" ]] || error "Archive is missing cn/cn.js." -tar -xzf "$TMP_DIR/cn-node.tar.gz" -C "$TMP_DIR" +# Idempotent: replace any previous install, but keep working tree between runs. rm -rf "$INSTALL_ROOT/current" mv "$TMP_DIR/cn" "$INSTALL_ROOT/current" cat > "$INSTALL_ROOT/current/cn" <<'WRAPPER' #!/usr/bin/env sh -exec node "$(dirname "$0")/cn.js" "$@" +# Resolve the symlink so that `$(dirname "$0")` always points at the +# real install directory even when the wrapper is invoked through +# ~/.local/bin/cn -> .../current/cn. +self="$0" +while [ -L "$self" ]; do + target="$(readlink "$self")" + case "$target" in + /*) self="$target" ;; + *) self="$(dirname "$self")/$target" ;; + esac +done +exec node "$(dirname "$self")/cn.js" "$@" WRAPPER chmod +x "$INSTALL_ROOT/current/cn.js" "$INSTALL_ROOT/current/cn" ln -sfn "$INSTALL_ROOT/current/cn" "$BIN_DIR/cn" printf '\nInstalled cn at %s\n' "$BIN_DIR/cn" + +# Idempotency: refresh the symlink even if the target was already there. +# (ln -sfn above already handles this; this is a no-op safeguard.) + if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then - printf 'Add %s to PATH, for example:\n' "$BIN_DIR" - printf ' export PATH="$HOME/.local/bin:$PATH"\n' + shell_name="$(detect_shell)" + printf 'Add %s to PATH. For %s, add this line to your shell profile:\n' "$BIN_DIR" "$shell_name" + printf ' %s\n' "$(path_hint "$shell_name" "$BIN_DIR")" fi printf 'Run: cn --help\n' diff --git a/extensions/cli/src/compaction.infiniteLoop.test.ts b/extensions/cli/src/compaction.infiniteLoop.test.ts index d31734d6d81..ab1166798ea 100644 --- a/extensions/cli/src/compaction.infiniteLoop.test.ts +++ b/extensions/cli/src/compaction.infiniteLoop.test.ts @@ -4,12 +4,15 @@ import { convertToUnifiedHistory } from "core/util/messageConversion.js"; import { describe, expect, it, vi } from "vitest"; import { compactChatHistory } from "./compaction.js"; -import { streamChatResponse } from "./stream/streamChatResponse.js"; +import { streamChatResponseRef } from "./stream/streamChatResponse.lateRef.js"; -// Mock the dependencies -vi.mock("./stream/streamChatResponse.js", () => ({ - streamChatResponse: vi.fn(), +// Mock the late-bound stream ref that `compaction.ts` consumes. See +// `streamChatResponse.lateRef.ts` for why this indirection exists. +vi.mock("./stream/streamChatResponse.lateRef.js", () => ({ + streamChatResponseRef: { current: null }, })); +const mockStreamResponse: ReturnType = vi.fn(); +streamChatResponseRef.current = mockStreamResponse; vi.mock("./util/tokenizer.js", () => ({ countChatHistoryTokens: vi.fn(), @@ -34,7 +37,6 @@ describe("compaction infinite loop prevention", () => { const { countChatHistoryTokens, getModelContextLimit } = await import( "./util/tokenizer.js" ); - const mockStreamResponse = vi.mocked(streamChatResponse); const mockCountTokens = vi.mocked(countChatHistoryTokens); const mockGetContextLimit = vi.mocked(getModelContextLimit); @@ -66,7 +68,6 @@ describe("compaction infinite loop prevention", () => { const { countChatHistoryTokens, getModelContextLimit } = await import( "./util/tokenizer.js" ); - const mockStreamResponse = vi.mocked(streamChatResponse); const mockCountTokens = vi.mocked(countChatHistoryTokens); const mockGetContextLimit = vi.mocked(getModelContextLimit); @@ -98,7 +99,6 @@ describe("compaction infinite loop prevention", () => { const { countChatHistoryTokens, getModelContextLimit } = await import( "./util/tokenizer.js" ); - const mockStreamResponse = vi.mocked(streamChatResponse); const mockCountTokens = vi.mocked(countChatHistoryTokens); const mockGetContextLimit = vi.mocked(getModelContextLimit); diff --git a/extensions/cli/src/compaction.pruneLastMessage.test.ts b/extensions/cli/src/compaction.pruneLastMessage.test.ts index a9ed83da433..7c5e6871d31 100644 --- a/extensions/cli/src/compaction.pruneLastMessage.test.ts +++ b/extensions/cli/src/compaction.pruneLastMessage.test.ts @@ -1,8 +1,8 @@ import { convertToUnifiedHistory } from "core/util/messageConversion.js"; import { describe, expect, it } from "vitest"; -import { pruneLastMessage } from "./compaction.js"; import { logger } from "./util/logger.js"; +import { pruneLastMessage } from "./util/chatHistoryPrune.js"; describe("pruneLastMessage", () => { it("should return empty array for empty input", () => { diff --git a/extensions/cli/src/compaction.test.ts b/extensions/cli/src/compaction.test.ts index 6c3d6df4e0b..b39dc5343f3 100644 --- a/extensions/cli/src/compaction.test.ts +++ b/extensions/cli/src/compaction.test.ts @@ -9,12 +9,15 @@ import { findCompactionIndex, getHistoryForLLM, } from "./compaction.js"; -import { streamChatResponse } from "./stream/streamChatResponse.js"; +import { streamChatResponseRef } from "./stream/streamChatResponse.lateRef.js"; -// Mock the streamChatResponse function -vi.mock("./stream/streamChatResponse.js", () => ({ - streamChatResponse: vi.fn(), +// Mock the late-bound stream ref that `compaction.ts` consumes. See +// `streamChatResponse.lateRef.ts` for why this indirection exists. +const mockStreamResponse: ReturnType = vi.fn(); +vi.mock("./stream/streamChatResponse.lateRef.js", () => ({ + streamChatResponseRef: { current: null }, })); +streamChatResponseRef.current = mockStreamResponse; describe("compaction", () => { const mockModel: ModelConfig = { @@ -332,7 +335,6 @@ describe("compaction", () => { describe("compactChatHistory", () => { it("should compact chat history successfully", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockContent = "This is a summary of the conversation"; mockStreamResponse.mockImplementation( @@ -372,7 +374,6 @@ describe("compaction", () => { }); it("should handle callbacks correctly", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockContent = "Summary content"; const onStreamContent = vi.fn(); const onStreamComplete = vi.fn(); @@ -403,7 +404,6 @@ describe("compaction", () => { }); it("should handle errors correctly", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockError = new Error("Stream failed"); const onError = vi.fn(); @@ -423,7 +423,6 @@ describe("compaction", () => { }); it("should handle history with only system message", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockContent = "Summary of system setup"; mockStreamResponse.mockImplementation( @@ -451,7 +450,6 @@ describe("compaction", () => { }); it("should handle empty content from stream", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { @@ -472,7 +470,6 @@ describe("compaction", () => { }); it("should correctly construct prompt for compaction", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); let capturedHistory: ChatHistoryItem[] = []; mockStreamResponse.mockImplementation( @@ -501,7 +498,6 @@ describe("compaction", () => { }); it("should handle history with tool calls and mixed message types", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockContent = "Summary including tool usage"; mockStreamResponse.mockImplementation( @@ -527,7 +523,6 @@ describe("compaction", () => { }); it("should handle very long chat histories", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { @@ -560,7 +555,6 @@ describe("compaction", () => { }); it("should handle history without system message", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); const mockContent = "Summary without system"; mockStreamResponse.mockImplementation( @@ -668,7 +662,6 @@ describe("compaction", () => { }); it("compaction should always reduce message count for non-trivial histories", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { @@ -706,7 +699,6 @@ describe("compaction", () => { describe("invariant tests", () => { it("compactionIndex should always point to a message with conversationSummary", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { @@ -740,7 +732,6 @@ describe("compaction", () => { }); it("system message should always be preserved in the same position", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { @@ -763,7 +754,6 @@ describe("compaction", () => { }); it("findCompactionIndex should be consistent with compactChatHistory result", async () => { - const mockStreamResponse = vi.mocked(streamChatResponse); mockStreamResponse.mockImplementation( async (history, model, api, controller, callbacks) => { diff --git a/extensions/cli/src/compaction.ts b/extensions/cli/src/compaction.ts index 4132bb97fd9..b0c03cb108d 100644 --- a/extensions/cli/src/compaction.ts +++ b/extensions/cli/src/compaction.ts @@ -4,8 +4,8 @@ import type { ChatHistoryItem } from "core/index.js"; import { encode } from "gpt-tokenizer"; import { ChatCompletionTool } from "openai/resources.mjs"; -import { streamChatResponse } from "./stream/streamChatResponse.js"; -import { StreamCallbacks } from "./stream/streamChatResponse.types.js"; +import { streamChatResponseRef } from "./stream/streamChatResponse.lateRef.js"; +import { pruneLastMessage } from "./util/chatHistoryPrune.js"; import { logger } from "./util/logger.js"; import { countChatHistoryTokens, @@ -117,7 +117,7 @@ export async function compactChatHistory( const controller = abortController || new AbortController(); let compactionContent = ""; - const streamCallbacks: StreamCallbacks = { + const streamCallbacks = { onContent: (content: string) => { compactionContent += content; callbacks?.onStreamContent?.(content); @@ -127,8 +127,17 @@ export async function compactChatHistory( }, }; + // Resolve the late-bound stream function at call time (see + // `streamChatResponse.lateRef.ts` for the cycle-breaking rationale). + const streamFn = streamChatResponseRef.current; + if (!streamFn) { + throw new Error( + "streamChatResponse is not yet registered; the CLI bundle is misconfigured", + ); + } + try { - await streamChatResponse( + await streamFn( historyForCompaction, model, llmApi, @@ -187,38 +196,11 @@ export function findCompactionIndex( * @returns The history to send to the LLM */ /** - * Prunes chat history by removing messages from the end while ensuring - * the history ends with either an assistant message or a tool result message - * @param chatHistory The chat history to prune - * @returns The pruned chat history ending with assistant or tool message + * Gets the history to send to the LLM, taking compaction into account + * @param fullHistory The complete chat history + * @param compactionIndex The index of the compaction message, if any + * @returns The history to send to the LLM */ -export function pruneLastMessage( - chatHistory: ChatHistoryItem[], -): ChatHistoryItem[] { - if (chatHistory.length === 0) { - return chatHistory; - } - - if (chatHistory.length === 1) { - // Only one message - always return empty array - return []; - } - - const secondToLastIndex = chatHistory.length - 2; - const secondToLastItem = chatHistory[secondToLastIndex]; - - if ( - secondToLastItem.message.role === "assistant" && - (secondToLastItem.message as any).toolCalls?.length > 0 - ) { - return chatHistory.slice(0, -2); - } else if (secondToLastItem.message.role === "user") { - return chatHistory.slice(0, -2); - } - - return chatHistory.slice(0, -1); -} - export function getHistoryForLLM( fullHistory: ChatHistoryItem[], compactionIndex: number | null, diff --git a/extensions/cli/src/onboardingProviders.test.ts b/extensions/cli/src/onboardingProviders.test.ts index 236a6f8f3a0..1a90bc6b4fa 100644 --- a/extensions/cli/src/onboardingProviders.test.ts +++ b/extensions/cli/src/onboardingProviders.test.ts @@ -52,4 +52,24 @@ describe("onboarding providers", () => { expect(updated).toContain("Existing"); expect(updated).toContain("OPENAI_API_KEY"); }); + + test("Azure provider requires deployment, apiType and apiVersion in env", () => { + const updated = updateProviderModelInYaml("", { + name: "Microsoft Azure AI", + provider: "azure", + model: "gpt-5", + apiKeySecret: "AZURE_API_KEY", + apiBase: "https://example.openai.azure.com", + env: { + deployment: "gpt-5-deploy", + apiType: "azure-openai", + apiVersion: "2023-07-01-preview", + }, + }); + + expect(updated).toContain("deployment: gpt-5-deploy"); + expect(updated).toContain("apiType: azure-openai"); + expect(updated).toContain("apiVersion: 2023-07-01-preview"); + expect(updated).toContain("apiBase: https://example.openai.azure.com"); + }); }); diff --git a/extensions/cli/src/onboardingProviders.ts b/extensions/cli/src/onboardingProviders.ts index 1dae285d1c1..79bb4f69c38 100644 --- a/extensions/cli/src/onboardingProviders.ts +++ b/extensions/cli/src/onboardingProviders.ts @@ -8,6 +8,9 @@ export interface OnboardingProvider { apiBase?: string; needsApiBase?: boolean; needsRegion?: boolean; + // Azure requires a deployment name and an apiType/apiVersion pair + // (see https://docs.continue.dev/customize/model-providers/top-level/azure). + needsAzureDeployment?: boolean; custom?: boolean; } @@ -96,6 +99,7 @@ export const ONBOARDING_PROVIDERS: OnboardingProvider[] = [ provider: "azure", envVar: "AZURE_API_KEY", needsApiBase: true, + needsAzureDeployment: true, defaultModel: "gpt-5", }, { diff --git a/extensions/cli/src/stream/streamChatResponse.lateRef.ts b/extensions/cli/src/stream/streamChatResponse.lateRef.ts new file mode 100644 index 00000000000..8006ce7ac1e --- /dev/null +++ b/extensions/cli/src/stream/streamChatResponse.lateRef.ts @@ -0,0 +1,36 @@ +import type { ModelConfig } from "@continuedev/config-yaml"; +import type { BaseLlmApi } from "@continuedev/openai-adapters"; +import type { ChatHistoryItem } from "core/index.js"; + +import type { StreamCallbacks } from "./streamChatResponse.types.js"; + +// eslint-disable-next-line max-params +export type StreamChatResponseFn = ( + chatHistory: ChatHistoryItem[], + model: ModelConfig, + llmApi: BaseLlmApi, + abortController: AbortController, + callbacks?: StreamCallbacks, + isCompacting?: boolean, +) => Promise; + +/** + * Late-bound reference to `streamChatResponse`. + * + * `compaction.ts` (and `subagent/executor.ts`) need to call + * `streamChatResponse` at runtime, but importing it directly creates a + * static import cycle: + * compaction -> streamChatResponse -> compaction (via pruneLastMessage) + * -> subagent/executor + * which esbuild resolves into a `__esm` factory with a top-level `await` + * that is not marked `async`, producing `SyntaxError: Unexpected reserved + * word` in the bundled CLI. + * + * Instead, `streamChatResponse.ts` registers itself here at module + * evaluation time, and `compaction.ts` / `subagent/executor.ts` read the + * registered value at call time. All involved files statically depend only + * on this leaf module, so the cycle is broken. + */ +export const streamChatResponseRef: { current: StreamChatResponseFn | null } = { + current: null, +}; diff --git a/extensions/cli/src/stream/streamChatResponse.ts b/extensions/cli/src/stream/streamChatResponse.ts index 20156663381..867dbe51444 100644 --- a/extensions/cli/src/stream/streamChatResponse.ts +++ b/extensions/cli/src/stream/streamChatResponse.ts @@ -8,11 +8,11 @@ import type { ChatCompletionTool, } from "openai/resources.mjs"; -import { pruneLastMessage } from "../compaction.js"; import { services } from "../services/index.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { applyChatCompletionToolOverrides } from "../tools/applyToolOverrides.js"; import { ToolCall } from "../tools/index.js"; +import { pruneLastMessage } from "../util/chatHistoryPrune.js"; import { chatCompletionStreamWithBackoff, isContextLengthError, @@ -33,6 +33,7 @@ import { recordStreamTelemetry, trackFirstTokenTime, } from "./streamChatResponse.helpers.js"; +import { streamChatResponseRef } from "./streamChatResponse.lateRef.js"; import { getDefaultCompletionOptions, StreamCallbacks, @@ -589,3 +590,8 @@ export async function streamChatResponse( // Otherwise, return the full response return isHeadless ? finalResponse : fullResponse; } + +// Register the resolved function for the late-bound ref consumed by +// `compaction.ts` and `subagent/executor.ts`. See +// `streamChatResponse.lateRef.ts` for the cycle-breaking rationale. +streamChatResponseRef.current = streamChatResponse; diff --git a/extensions/cli/src/subagent/executor.ts b/extensions/cli/src/subagent/executor.ts index 1580e7f7f9d..956e2f1d6bd 100644 --- a/extensions/cli/src/subagent/executor.ts +++ b/extensions/cli/src/subagent/executor.ts @@ -4,7 +4,7 @@ import { services } from "../services/index.js"; import { serviceContainer } from "../services/ServiceContainer.js"; import type { ToolPermissionServiceState } from "../services/ToolPermissionService.js"; import { ModelServiceState, SERVICE_NAMES } from "../services/types.js"; -import { streamChatResponse } from "../stream/streamChatResponse.js"; +import { streamChatResponseRef } from "../stream/streamChatResponse.lateRef.js"; import { escapeEvents } from "../util/cli.js"; import { logger } from "../util/logger.js"; @@ -137,8 +137,17 @@ export async function executeSubAgent( try { let accumulatedOutput = ""; + // Resolve the late-bound stream function at call time (see + // `streamChatResponse.lateRef.ts` for the cycle-breaking rationale). + const streamFn = streamChatResponseRef.current; + if (!streamFn) { + throw new Error( + "streamChatResponse is not yet registered; the CLI bundle is misconfigured", + ); + } + // Execute the chat stream with child session - await streamChatResponse( + await streamFn( chatHistory, model, llmApi, diff --git a/extensions/cli/src/ui/components/ProviderPicker.tsx b/extensions/cli/src/ui/components/ProviderPicker.tsx index 4f771db9cc6..647e96c9e79 100644 --- a/extensions/cli/src/ui/components/ProviderPicker.tsx +++ b/extensions/cli/src/ui/components/ProviderPicker.tsx @@ -13,7 +13,13 @@ interface ProviderPickerProps { onCancel: () => void; } -type Step = "provider" | "apiBase" | "region" | "model" | "confirm"; +type Step = + | "provider" + | "apiBase" + | "region" + | "azureDeployment" + | "model" + | "confirm"; type InputKey = Parameters[0]>[1]; type ProviderInputContext = { @@ -32,8 +38,10 @@ type FieldInputContext = { inputValue: string; apiBase: string; region: string; + azureDeployment: string; setApiBase: React.Dispatch>; setRegion: React.Dispatch>; + setAzureDeployment: React.Dispatch>; setInputValue: React.Dispatch>; setStep: React.Dispatch>; onSelect: (config: SelectedProviderConfig) => void; @@ -97,7 +105,10 @@ function handleProviderInput( } if (!key.return) return false; - if (provider.needsApiBase) { + if (provider.needsAzureDeployment) { + setInputValue(""); + setStep("apiBase"); + } else if (provider.needsApiBase) { setInputValue(""); setStep("apiBase"); } else if (provider.needsRegion) { @@ -122,8 +133,10 @@ function handleFieldInput( inputValue, apiBase, region, + azureDeployment, setApiBase, setRegion, + setAzureDeployment, setInputValue, setStep, onSelect, @@ -158,20 +171,38 @@ function handleFieldInput( const defaultModel = provider.defaultModel ?? ""; setApiBase(value); setInputValue(defaultModel); - setStep("model"); + if (provider.needsAzureDeployment) { + setStep("azureDeployment"); + } else { + setStep("model"); + } } else if (step === "region") { const defaultModel = provider.defaultModel ?? ""; setRegion(value); setInputValue(defaultModel); setStep("model"); + } else if (step === "azureDeployment") { + const defaultModel = provider.defaultModel ?? ""; + setAzureDeployment(value); + setInputValue(defaultModel); + setStep("model"); } else if (step === "model") { + const env: Record = {}; + if (region) env.region = region; + if (provider.needsAzureDeployment && azureDeployment) { + // Azure requires deployment + apiType + apiVersion in env. + // Defaults match the Continue Azure docs examples. + env.deployment = azureDeployment; + env.apiType = "azure-openai"; + env.apiVersion = "2023-07-01-preview"; + } onSelect({ name: provider.label, provider: provider.provider, model: value, apiKeySecret: provider.envVar, apiBase: provider.needsApiBase ? apiBase : provider.apiBase, - env: region ? { region } : undefined, + env: Object.keys(env).length > 0 ? env : undefined, }); setInputValue(""); setStep("confirm"); @@ -186,6 +217,7 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { const [inputValue, setInputValue] = useState(""); const [apiBase, setApiBase] = useState(""); const [region, setRegion] = useState(""); + const [azureDeployment, setAzureDeployment] = useState(""); const provider = ONBOARDING_PROVIDERS[selectedIndex]; const pageSize = Math.max(5, Math.min(12, terminalRows - 8)); @@ -225,8 +257,10 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { inputValue, apiBase, region, + azureDeployment, setApiBase, setRegion, + setAzureDeployment, setInputValue, setStep, onSelect, @@ -263,12 +297,23 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { } if (step !== "provider") { - const label = - step === "apiBase" - ? "API base URL" - : step === "region" - ? "AWS region" - : `Model [default: ${provider.defaultModel ?? "enter a model"}]`; + let label: string; + switch (step) { + case "apiBase": + label = "API base URL"; + break; + case "region": + label = "AWS region"; + break; + case "azureDeployment": + label = "Azure deployment name"; + break; + case "model": + label = `Model [default: ${provider.defaultModel ?? "enter a model"}]`; + break; + default: + label = ""; + } return ( 0) { + return chatHistory.slice(0, -2); + } else if (message.role === "user") { + return chatHistory.slice(0, -2); + } + + return chatHistory.slice(0, -1); +} diff --git a/extensions/cli/src/version.ts b/extensions/cli/src/version.ts index af3e2a27836..4d70da45bed 100644 --- a/extensions/cli/src/version.ts +++ b/extensions/cli/src/version.ts @@ -10,11 +10,26 @@ export function getVersion(): string { try { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); - const packageJsonPath = join(__dirname, "../package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); - return packageJson.version; + // Look for `package.json` next to the running module first (this is the + // shape of the packaged release artifact: `cn/cn.js` + `cn/package.json`). + // Fall back to the parent directory for the dev layout (`dist/index.js` + // with `package.json` at the package root). + const candidates = [ + join(__dirname, "package.json"), + join(__dirname, "../package.json"), + ]; + for (const packageJsonPath of candidates) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (typeof packageJson.version === "string") { + return packageJson.version; + } + } catch { + // Try the next candidate. + } + } + return "unknown"; } catch { - console.warn("Warning: Could not read version from package.json"); return "unknown"; } }