diff --git a/.changeset/extract-reusable-skill-manager.md b/.changeset/extract-reusable-skill-manager.md new file mode 100644 index 00000000..a3ca08ff --- /dev/null +++ b/.changeset/extract-reusable-skill-manager.md @@ -0,0 +1,6 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Extract reusable Skill Manager domain, repositories, and orchestration services, then connect +Coder Studio through host adapters while preserving existing Skill command behavior. diff --git a/.changeset/fix-agent-runtime-port.md b/.changeset/fix-agent-runtime-port.md new file mode 100644 index 00000000..1727ae1f --- /dev/null +++ b/.changeset/fix-agent-runtime-port.md @@ -0,0 +1,6 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Inject the actual OS-assigned backend port into agent sessions so Canvas and other automation +commands can connect in packaged Windows and WSL Desktop environments. diff --git a/.changeset/share-desktop-theme-preferences.md b/.changeset/share-desktop-theme-preferences.md new file mode 100644 index 00000000..30c905e7 --- /dev/null +++ b/.changeset/share-desktop-theme-preferences.md @@ -0,0 +1,7 @@ +--- +"@spencer-kit/coder-studio": patch +"@coder-studio/desktop": patch +--- + +Share the selected theme across Local Windows and WSL Desktop windows through a locked Desktop +preference store with live cross-process updates and legacy browser settings fallback. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index bc117629..992939d0 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -146,6 +146,7 @@ jobs: environment: desktop-production outputs: current_product_tag: ${{ steps.identity.outputs.current_product_tag }} + current_product_index_sha256: ${{ steps.identity.outputs.current_product_index_sha256 }} current_product_channel_sha256: ${{ steps.identity.outputs.current_product_channel_sha256 }} current_windows_manifest_sha256: ${{ steps.identity.outputs.current_windows_manifest_sha256 }} current_linux_manifest_sha256: ${{ steps.identity.outputs.current_linux_manifest_sha256 }} @@ -158,7 +159,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} CODER_STUDIO_RUNTIME_PUBLIC_KEY: ${{ secrets.DESKTOP_RUNTIME_PUBLIC_KEY }} - CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_RUNTIME_SIGNING_PRIVATE_KEY }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -175,9 +175,14 @@ jobs: node-version: "24" cache: pnpm - - name: Install dependencies - run: pnpm install --frozen-lockfile - + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build target Desktop metadata + env: + CODER_STUDIO_RELEASE_PUBLISHED_AT: ${{ needs.prepare.outputs.published_at }} + run: pnpm build:desktop + - name: Resolve Product selected for Factory packaging id: product env: @@ -188,52 +193,75 @@ jobs: echo "The production Runtime public key is required" >&2 exit 1 fi - mkdir -p release/factory-resolution/stable release/factory-resolution/desktop - stable_tag="" - if gh release download product-stable \ - --pattern product-channel.json \ - --dir release/factory-resolution/stable 2>/dev/null; then - stable_tag=$(node -p "require('./release/factory-resolution/stable/product-channel.json').releaseTag") - else - stable_tag=$(gh release list --limit 100 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[] | select(.tagName | startswith("v")) | .tagName' | head -n 1) - if [[ -z "${stable_tag}" ]]; then - echo "No stable Product release is available for Desktop publication" >&2 - exit 1 - fi - fi - selected_tag="${stable_tag}" + mkdir -p \ + release/factory-resolution/stable \ + release/factory-resolution/desktop \ + release/factory-resolution/keys \ + release/factory-resolution/selection + printf '%s' "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" \ + > release/factory-resolution/keys/runtime-public.pem + gh release download product-stable \ + --pattern product-index.json \ + --dir release/factory-resolution/stable + required_version="" if [[ "${RESUME_CANDIDATE}" == "true" ]]; then gh release download "${CANDIDATE_TAG}" \ --pattern desktop-channel.json \ --dir release/factory-resolution/desktop - selected_tag=$(node -p "require('./release/factory-resolution/desktop/desktop-channel.json').factoryProduct.releaseTag") - fi - echo "selected_tag=${selected_tag}" >> "${GITHUB_OUTPUT}" - + required_version=$(node -p "require('./release/factory-resolution/desktop/desktop-channel.json').factoryProduct.version") + fi + node --import tsx scripts/select-product-index-release.ts \ + --index release/factory-resolution/stable/product-index.json \ + --build-info packages/desktop/dist/build-info.json \ + --public-key release/factory-resolution/keys/runtime-public.pem \ + --output release/factory-resolution/selection/current.json + selected_tag=$(node -p "require('./release/factory-resolution/selection/current.json').releaseTag") + selected_version=$(node -p "require('./release/factory-resolution/selection/current.json').version") + if [[ -n "${required_version}" && "${required_version}" != "${selected_version}" ]]; then + echo "Desktop candidate Factory Runtime ${required_version} is no longer the highest compatible accepted Runtime ${selected_version}; publish a new Desktop version" >&2 + exit 1 + fi + echo "selected_tag=${selected_tag}" >> "${GITHUB_OUTPUT}" + echo "selected_version=${selected_version}" >> "${GITHUB_OUTPUT}" + - name: Download and validate accepted Product bundle env: PRODUCT_TAG: ${{ steps.product.outputs.selected_tag }} run: | - if [[ -z "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" ]]; then - echo "The production Runtime signing private key is required" >&2 + mkdir -p release/factory-resolution/product/current release/factory-resolution/source + selected=release/factory-resolution/selection/current.json + windows_manifest=$(node -p "require('./${selected}').runtimes['win32-x64'].manifest") + linux_manifest=$(node -p "require('./${selected}').runtimes['linux-x64'].manifest") + gh release download "${PRODUCT_TAG}" \ + --pattern product-channel.json \ + --pattern coder-studio-cli.tgz \ + --pattern "${windows_manifest}" \ + --pattern "${linux_manifest}" \ + --dir release/factory-resolution/product/current + windows_digest=$(sha256sum "release/factory-resolution/product/current/${windows_manifest}" | cut -d ' ' -f 1) + linux_digest=$(sha256sum "release/factory-resolution/product/current/${linux_manifest}" | cut -d ' ' -f 1) + expected_windows_digest=$(node -p "require('./${selected}').runtimes['win32-x64'].manifestSha256") + expected_linux_digest=$(node -p "require('./${selected}').runtimes['linux-x64'].manifestSha256") + if [[ "${windows_digest}" != "${expected_windows_digest}" || "${linux_digest}" != "${expected_linux_digest}" ]]; then + echo "Selected Product Runtime manifest digest does not match the signed Product index" >&2 exit 1 fi - mkdir -p release/factory-resolution/product/current release/factory-resolution/source release/factory-resolution/keys - printf '%s\n' "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" \ - > release/factory-resolution/keys/runtime-signing-private.pem - gh release download "${PRODUCT_TAG}" --dir release/factory-resolution/product/current - pnpm exec tsx scripts/prepare-product-release-bundle.ts \ - --directory release/factory-resolution/product/current \ - --release-tag "${PRODUCT_TAG}" \ - --private-key release/factory-resolution/keys/runtime-signing-private.pem + windows_package=$(node -p "require('./release/factory-resolution/product/current/${windows_manifest}').packageFile") + linux_package=$(node -p "require('./release/factory-resolution/product/current/${linux_manifest}').packageFile") + gh release download "${PRODUCT_TAG}" \ + --pattern "${windows_package}" \ + --pattern "${linux_package}" \ + --dir release/factory-resolution/product/current tar -xOf release/factory-resolution/product/current/coder-studio-cli.tgz package/package.json \ > release/factory-resolution/source/current-package.json pnpm release:artifacts validate-product \ --directory release/factory-resolution/product/current \ --source-package-json release/factory-resolution/source/current-package.json resolved_tag=$(node -p "require('./release/factory-resolution/product/current/product-channel.json').releaseTag") - if [[ "${resolved_tag}" != "${PRODUCT_TAG}" ]]; then - echo "Accepted Product channel does not match its immutable release tag" >&2 + resolved_version=$(node -p "require('./release/factory-resolution/product/current/product-channel.json').version") + selected_version=$(node -p "require('./${selected}').version") + if [[ "${resolved_tag}" != "${PRODUCT_TAG}" || "${resolved_version}" != "${selected_version}" ]]; then + echo "Accepted Product channel does not match the signed Product index selection" >&2 exit 1 fi @@ -261,8 +289,16 @@ jobs: manifestSha256: channel.runtimes["linux-x64"].manifestSha256, }, }, - }, null, 2)}\n`); - NODE + }, null, 2)}\n`); + NODE + selected_version=$(node -p "require('./release/factory-resolution/selection/current.json').version") + factory_version=$(node -p "require('./release/factory-product/factory-product.json').version") + packaged_runtime_version=$(node -p "require('./release/factory-product/factory-runtime/manifest.json').runtimeVersion") + if [[ "${selected_version}" != "${factory_version}" || \ + "${selected_version}" != "${packaged_runtime_version}" ]]; then + echo "Factory Runtime does not match the signed Product index selection" >&2 + exit 1 + fi - name: Resolve current and previous Product compatibility identities id: identity @@ -278,39 +314,55 @@ jobs: previous_channel_digest="" previous_windows_digest="" previous_linux_digest="" - while IFS= read -r release_tag; do - [[ -n "${release_tag}" ]] || continue - if [[ "${release_tag}" == "${CURRENT_PRODUCT_TAG}" || "${release_tag}" == "product-stable" || "${release_tag}" == "desktop-stable" ]]; then - continue - fi - rm -rf release/factory-resolution/product/previous - mkdir -p release/factory-resolution/product/previous - if ! gh release download "${release_tag}" \ - --dir release/factory-resolution/product/previous 2>/dev/null; then - continue - fi - if ! pnpm exec tsx scripts/prepare-product-release-bundle.ts \ - --directory release/factory-resolution/product/previous \ - --release-tag "${release_tag}" \ - --private-key release/factory-resolution/keys/runtime-signing-private.pem >/dev/null; then - continue - fi - declared_tag=$(node -p "require('./release/factory-resolution/product/previous/product-channel.json').releaseTag" 2>/dev/null || true) - if [[ "${declared_tag}" != "${release_tag}" ]]; then - continue - fi - previous_product_tag="${release_tag}" - previous_channel_digest=$(sha256sum release/factory-resolution/product/previous/product-channel.json | cut -d ' ' -f 1) - previous_windows_digest=$(sha256sum release/factory-resolution/product/previous/coder-studio-runtime-win32-x64.manifest.json | cut -d ' ' -f 1) - previous_linux_digest=$(sha256sum release/factory-resolution/product/previous/coder-studio-server-runtime-linux-x64.manifest.json | cut -d ' ' -f 1) - break - done < <(gh release list --limit 100 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[].tagName') - - if [[ -z "${previous_product_tag}" ]]; then - echo "Desktop acceptance requires the immediately previous accepted Product release" >&2 + current_version=$(node -p "require('./release/factory-resolution/selection/current.json').version") + node --import tsx scripts/select-product-index-release.ts \ + --index release/factory-resolution/stable/product-index.json \ + --build-info packages/desktop/dist/build-info.json \ + --public-key release/factory-resolution/keys/runtime-public.pem \ + --previous-to-version "${current_version}" \ + --output release/factory-resolution/selection/previous.json + previous_product_tag=$(node -p "require('./release/factory-resolution/selection/previous.json').releaseTag") + previous_windows_manifest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['win32-x64'].manifest") + previous_linux_manifest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['linux-x64'].manifest") + mkdir -p release/factory-resolution/product/previous + gh release download "${previous_product_tag}" \ + --pattern product-channel.json \ + --pattern coder-studio-cli.tgz \ + --pattern "${previous_windows_manifest}" \ + --pattern "${previous_linux_manifest}" \ + --dir release/factory-resolution/product/previous + previous_windows_digest=$(sha256sum "release/factory-resolution/product/previous/${previous_windows_manifest}" | cut -d ' ' -f 1) + previous_linux_digest=$(sha256sum "release/factory-resolution/product/previous/${previous_linux_manifest}" | cut -d ' ' -f 1) + expected_previous_windows_digest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['win32-x64'].manifestSha256") + expected_previous_linux_digest=$(node -p "require('./release/factory-resolution/selection/previous.json').runtimes['linux-x64'].manifestSha256") + if [[ "${previous_windows_digest}" != "${expected_previous_windows_digest}" || \ + "${previous_linux_digest}" != "${expected_previous_linux_digest}" ]]; then + echo "Previous Product Runtime manifest digest does not match the signed Product index" >&2 exit 1 fi + previous_windows_package=$(node -p "require('./release/factory-resolution/product/previous/${previous_windows_manifest}').packageFile") + previous_linux_package=$(node -p "require('./release/factory-resolution/product/previous/${previous_linux_manifest}').packageFile") + gh release download "${previous_product_tag}" \ + --pattern "${previous_windows_package}" \ + --pattern "${previous_linux_package}" \ + --dir release/factory-resolution/product/previous + tar -xOf release/factory-resolution/product/previous/coder-studio-cli.tgz package/package.json \ + > release/factory-resolution/source/previous-package.json + pnpm release:artifacts validate-product \ + --directory release/factory-resolution/product/previous \ + --source-package-json release/factory-resolution/source/previous-package.json + declared_previous_tag=$(node -p "require('./release/factory-resolution/product/previous/product-channel.json').releaseTag") + declared_previous_version=$(node -p "require('./release/factory-resolution/product/previous/product-channel.json').version") + selected_previous_version=$(node -p "require('./release/factory-resolution/selection/previous.json').version") + if [[ "${declared_previous_tag}" != "${previous_product_tag}" || \ + "${declared_previous_version}" != "${selected_previous_version}" ]]; then + echo "Previous Product channel does not match the signed Product index selection" >&2 + exit 1 + fi + previous_channel_digest=$(sha256sum release/factory-resolution/product/previous/product-channel.json | cut -d ' ' -f 1) + product_index_digest=$(sha256sum release/factory-resolution/stable/product-index.json | cut -d ' ' -f 1) echo "current_product_tag=${CURRENT_PRODUCT_TAG}" >> "${GITHUB_OUTPUT}" + echo "current_product_index_sha256=${product_index_digest}" >> "${GITHUB_OUTPUT}" echo "current_product_channel_sha256=${current_channel_digest}" >> "${GITHUB_OUTPUT}" echo "current_windows_manifest_sha256=${current_windows_digest}" >> "${GITHUB_OUTPUT}" echo "current_linux_manifest_sha256=${current_linux_digest}" >> "${GITHUB_OUTPUT}" @@ -1203,6 +1255,7 @@ jobs: DESKTOP_CHANNEL_SHA256: ${{ needs.publish-candidate.outputs.desktop_channel_sha256 }} ARTIFACT_DIGESTS: ${{ needs.publish-candidate.outputs.artifact_digests }} PREVIOUS_POINTER_DIGEST: ${{ needs.prepare.outputs.previous_desktop_channel_sha256 }} + ACCEPTED_PRODUCT_INDEX_SHA256: ${{ needs.resolve-factory-product.outputs.current_product_index_sha256 }} ACCEPTED_PRODUCT_CHANNEL_SHA256: ${{ needs.resolve-factory-product.outputs.current_product_channel_sha256 }} CANDIDATE_COMMIT: ${{ needs.publish-candidate.outputs.candidate_commit }} steps: @@ -1233,17 +1286,17 @@ jobs: - name: Revalidate accepted Product stable pointer run: | mkdir -p release/desktop-promotion/accepted-product - product_reference_tag="product-stable" accepted_product_channel=release/desktop-promotion/current-product/product-channel.json - if ! gh release download "${product_reference_tag}" \ - --pattern product-channel.json \ + if ! gh release download product-stable \ + --pattern product-index.json \ --dir release/desktop-promotion/accepted-product 2>/dev/null; then - accepted_product_channel=release/desktop-promotion/current-product/product-channel.json - else - accepted_product_channel=release/desktop-promotion/accepted-product/product-channel.json + echo "The signed Product stable index is required for Desktop promotion" >&2 + exit 1 fi current_digest=$(sha256sum "${accepted_product_channel}" | cut -d ' ' -f 1) - if [[ "${current_digest}" != "${ACCEPTED_PRODUCT_CHANNEL_SHA256}" ]]; then + current_index_digest=$(sha256sum release/desktop-promotion/accepted-product/product-index.json | cut -d ' ' -f 1) + if [[ "${current_digest}" != "${ACCEPTED_PRODUCT_CHANNEL_SHA256}" || \ + "${current_index_digest}" != "${ACCEPTED_PRODUCT_INDEX_SHA256}" ]]; then echo "Product stable pointer changed after compatibility acceptance; rerun the candidate acceptance graph" >&2 exit 1 fi diff --git a/.github/workflows/product-release.yml b/.github/workflows/product-release.yml index 245a46b3..9a96123e 100644 --- a/.github/workflows/product-release.yml +++ b/.github/workflows/product-release.yml @@ -746,9 +746,10 @@ jobs: - accept-cli - accept-runtime - compatibility - if: success() - runs-on: ubuntu-latest - timeout-minutes: 20 + if: success() + runs-on: ubuntu-latest + environment: desktop-production + timeout-minutes: 20 concurrency: group: product-desktop-stable-promotion cancel-in-progress: false @@ -770,6 +771,8 @@ jobs: PREVIOUS_POINTER_DIGEST: ${{ needs.prepare.outputs.previous_product_channel_sha256 }} ACCEPTED_DESKTOP_CHANNEL_SHA256: ${{ needs.prepare.outputs.desktop_channel_sha256 }} CANDIDATE_COMMIT: ${{ needs.publish-candidate.outputs.candidate_commit }} + CODER_STUDIO_RUNTIME_PUBLIC_KEY: ${{ secrets.DESKTOP_RUNTIME_PUBLIC_KEY }} + CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_RUNTIME_SIGNING_PRIVATE_KEY }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -821,17 +824,52 @@ jobs: echo "Immutable Product digest mismatch: npm integrity" >&2 exit 1 fi - - - name: Advance final npm dist-tag - run: npm dist-tag add "${PACKAGE_NAME}@${PRODUCT_VERSION}" "${FINAL_DIST_TAG}" + + - name: Build next signed Product index + id: product-index + run: | + if [[ -z "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" || -z "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" ]]; then + echo "Production Runtime signing keys are required to advance the Product index" >&2 + exit 1 + fi + mkdir -p \ + release/product-promotion/keys \ + release/product-promotion/index-bootstrap \ + release/product-promotion/next + printf '%s' "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" \ + > release/product-promotion/keys/runtime-public.pem + printf '%s' "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" \ + > release/product-promotion/keys/runtime-private.pem + index_args=() + if gh release download product-stable \ + --pattern product-index.json \ + --dir release/product-promotion/index-bootstrap 2>/dev/null; then + index_args+=(--existing-index release/product-promotion/index-bootstrap/product-index.json) + elif gh release download product-stable \ + --pattern product-channel.json \ + --dir release/product-promotion/index-bootstrap 2>/dev/null; then + index_args+=(--previous-channel release/product-promotion/index-bootstrap/product-channel.json) + fi + node --import tsx scripts/build-product-index.ts \ + --candidate-channel release/product-promotion/candidate/product-channel.json \ + "${index_args[@]}" \ + --public-key release/product-promotion/keys/runtime-public.pem \ + --private-key release/product-promotion/keys/runtime-private.pem \ + --output release/product-promotion/next/product-index.json + index_digest=$(sha256sum release/product-promotion/next/product-index.json | cut -d ' ' -f 1) + echo "product_index_sha256=${index_digest}" >> "${GITHUB_OUTPUT}" + + - name: Advance final npm dist-tag + run: npm dist-tag add "${PACKAGE_NAME}@${PRODUCT_VERSION}" "${FINAL_DIST_TAG}" - name: Promote Product versioned release run: gh release edit "${CANDIDATE_TAG}" --prerelease=false --latest=false - - name: Advance signed Product stable pointer - run: | - pointer=release/product-promotion/candidate/product-channel.json - final_digest=$(sha256sum "${pointer}" | cut -d ' ' -f 1) + - name: Advance signed Product stable pointer + run: | + pointer=release/product-promotion/candidate/product-channel.json + index_pointer=release/product-promotion/next/product-index.json + final_digest=$(sha256sum "${pointer}" | cut -d ' ' -f 1) if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" ]]; then echo "Immutable Product digest mismatch: product-channel.json" >&2 exit 1 @@ -844,16 +882,16 @@ jobs: if [[ "${current_digest}" != "${PREVIOUS_POINTER_DIGEST}" && "${current_digest}" != "${final_digest}" ]]; then echo "Product stable pointer changed after acceptance" >&2 exit 1 - fi - if gh release view product-stable >/dev/null 2>&1; then - gh release upload product-stable "${pointer}" --clobber - else - gh release create product-stable "${pointer}" \ - --target "${GITHUB_SHA}" \ - --title "Coder Studio Product stable channel" \ - --notes "Signed mutable Product channel pointer." \ - --latest=false - fi + fi + if gh release view product-stable >/dev/null 2>&1; then + gh release upload product-stable "${index_pointer}" "${pointer}" --clobber + else + gh release create product-stable "${index_pointer}" "${pointer}" \ + --target "${GITHUB_SHA}" \ + --title "Coder Studio Product stable channel" \ + --notes "Signed mutable Product channel and accepted release index." \ + --latest=false + fi - name: Verify promoted Product run: | @@ -871,13 +909,36 @@ jobs: sleep 10 done mkdir -p release/product-promotion/final - gh release download product-stable --pattern product-channel.json --dir release/product-promotion/final - final_digest=$(sha256sum release/product-promotion/final/product-channel.json | cut -d ' ' -f 1) - final_version=$(node -p "require('./release/product-promotion/final/product-channel.json').version") - if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" || "${final_version}" != "${PRODUCT_VERSION}" ]]; then - echo "Promoted Product stable pointer verification failed" >&2 - exit 1 - fi + gh release download product-stable --pattern 'product-*.json' --dir release/product-promotion/final + final_digest=$(sha256sum release/product-promotion/final/product-channel.json | cut -d ' ' -f 1) + final_index_digest=$(sha256sum release/product-promotion/final/product-index.json | cut -d ' ' -f 1) + final_version=$(node -p "require('./release/product-promotion/final/product-channel.json').version") + if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" || \ + "${final_index_digest}" != "${{ steps.product-index.outputs.product_index_sha256 }}" || \ + "${final_version}" != "${PRODUCT_VERSION}" ]]; then + echo "Promoted Product stable pointer verification failed" >&2 + exit 1 + fi + node --import tsx --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { parseProductChannel } from "./packages/desktop/src/product-channel.ts"; + import { parseProductIndex } from "./packages/desktop/src/product-index.ts"; + const root = "release/product-promotion/final"; + const key = readFileSync("release/product-promotion/keys/runtime-public.pem", "utf8"); + const channel = parseProductChannel( + JSON.parse(readFileSync(`${root}/product-channel.json`, "utf8")), + key, + "https://github.com/${{ github.repository }}/releases/download/product-stable/product-channel.json" + ); + const index = parseProductIndex( + JSON.parse(readFileSync(`${root}/product-index.json`, "utf8")), + key, + "https://github.com/${{ github.repository }}/releases/download/product-stable/product-index.json" + ); + if (channel.version !== process.env.PRODUCT_VERSION || index.latestVersion !== process.env.PRODUCT_VERSION) { + throw new Error("Promoted Product index does not contain the accepted latest Product"); + } + NODE - name: Remove temporary npm dist-tag run: | diff --git a/docs/promotion/product-desktop-release-runbook.md b/docs/promotion/product-desktop-release-runbook.md index dfc6918f..4448f5a0 100644 --- a/docs/promotion/product-desktop-release-runbook.md +++ b/docs/promotion/product-desktop-release-runbook.md @@ -3,15 +3,60 @@ Coder Studio has two independently owned production release boundaries: - A **Product release** publishes the CLI together with the Windows and WSL Product Runtime at one - Product version. Its signed pointer is `product-stable/product-channel.json`. + Product version. Product owns both `product-stable/product-channel.json` (the latest-only legacy + pointer) and `product-stable/product-index.json` (the accepted Runtime history). - A **Desktop release** publishes the Desktop Shell, Windows Engine, and WSL Engine at one Desktop version. It packages the already accepted Factory Runtime identified by the Desktop channel, but does not rebuild or republish Product Runtime. Its signed pointer is `desktop-stable/desktop-channel.json`. -The fixed pointer releases contain only signed channel documents. Channels name immutable versioned -release tags and safe asset names; clients never infer component state from GitHub's repository-wide -latest selection. +The fixed pointer releases contain only signed channel/index documents. They name immutable +versioned release tags and safe asset names; clients never infer component state by enumerating +GitHub Releases or from GitHub's repository-wide latest selection. + +`product-index.json` is the source of truth for new Shell builds and installed Shell updates. Every +successful Product promotion adds one accepted release and signs the complete index with the Runtime +Ed25519 key. Failed candidates never enter it, and Desktop publication has read-only access. A +consumer selects the highest semantic version compatible with its effective Shell version, Engine, +Node, Runtime Host API, API Protocol, and Data Schema. If the newest entry is incompatible, the +consumer continues with the next-highest accepted entry; if none is compatible, it fails closed. + +The schema is intentionally an index of immutable identities, not a second artifact store: + +```json +{ + "schemaVersion": 1, + "channel": "product-index", + "generatedAt": "2026-08-25T08:00:00.000Z", + "latestVersion": "0.5.13", + "releases": [ + { + "version": "0.5.13", + "releaseTag": "v0.5.13", + "publishedAt": "2026-08-25T07:30:00.000Z", + "minShellVersion": "0.1.5", + "requirements": { + "engineVersion": "2", + "nodeVersion": "24.19.0", + "runtimeHostApiVersion": 1, + "apiProtocolVersion": 1, + "dataSchemaVersion": 1 + }, + "runtimes": { + "win32-x64": { + "manifest": "coder-studio-runtime-win32-x64.manifest.json", + "manifestSha256": "<64 lowercase hex characters>" + }, + "linux-x64": { + "manifest": "coder-studio-server-runtime-linux-x64.manifest.json", + "manifestSha256": "<64 lowercase hex characters>" + } + } + } + ], + "signature": { "algorithm": "ed25519", "value": "" } +} +``` ## Normal Product release @@ -24,13 +69,25 @@ After every acceptance job succeeds, promotion is automatic. It advances the req (normally `latest`), converts the versioned Product prerelease to a normal non-latest release, advances `product-stable`, verifies the result, removes the temporary dist-tag, and writes `promotion.json`. There is no run-ID handoff, environment approval, or separate manual promotion. +The promotion job is the only writer of `product-index.json`: it verifies the accepted candidate and +existing index, bootstraps from the previous signed `product-channel.json` when no index exists, +merges idempotently, signs, uploads the index before the legacy pointer, and verifies the remote +bytes and signatures. ## Normal Desktop release Merging a release-ready Desktop version change in `packages/desktop/package.json` to `main` starts -`desktop-release.yml`. It resolves and verifies `product-stable`, embeds those exact Factory Runtime -bytes and their provenance, and builds only the Shell, installer, Windows Engine, and WSL Engine. The -candidate uses the final `desktop-v` tag. +`desktop-release.yml`. It builds the target `build-info.json`, requires the signed Product index, and +selects the highest compatible accepted Runtime. It downloads only the exact immutable tag, +manifests, and packages named by that entry, embeds those Factory Runtime bytes and their provenance, +and builds only the Shell, installer, Windows Engine, and WSL Engine. Missing/invalid indexes and an +empty compatible set are hard failures; normal publication never falls back to release enumeration +or the legacy bundle-recovery script. The candidate uses the final `desktop-v` tag. + +The selected index version must equal `factory-product.json`, the packaged Factory Runtime manifest, +and the Desktop channel's `factoryProduct.version`. Installed production Shells use the same index +selector. Explicit acceptance launches continue to consume their tag-pinned candidate +`product-channel.json`, because a candidate is not accepted and indexed until promotion. Installed/fresh, offline Factory/fallback, and current/previous Product compatibility acceptance all consume the immutable candidate. After they pass, promotion automatically converts the versioned @@ -47,6 +104,11 @@ it, and compares every accepted digest before continuing incomplete acceptance o The same rule applies to fixed pointers and `promotion.json`: an existing identical byte sequence is idempotent; an existing different byte sequence is a hard failure. +A resumed Desktop candidate is also compared with the current highest compatible Product index +entry. If a newer compatible Runtime was accepted after that candidate was built, the old candidate +cannot be published: increment the Desktop patch version and build a new candidate with the current +Factory Runtime. + An **immutable-byte mismatch** is not repaired with `--clobber`. Retain the failed release for diagnosis, increment to a higher patch version, and publish a new immutable candidate. Never move a stable pointer backward as a downgrade mechanism. @@ -57,7 +119,7 @@ Product and Desktop workflows use independent serialized locks (`product-product `desktop-production`) for candidate construction and acceptance. Their promotion jobs additionally share `product-desktop-stable-promotion`, so only one accepted tuple can move a stable pointer at a time. After acquiring that lock, Product rechecks the accepted `desktop-stable` digest and Desktop -rechecks the accepted `product-stable` digest before changing any external state. If the counterpart +rechecks the accepted Product index digest before changing any external state. If the counterpart changed after compatibility acceptance, promotion stops and the same `candidate_tag` must rerun the complete compatibility graph against the new stable tuple. @@ -78,8 +140,10 @@ Normal publication may update a pointer only after immutable candidate acceptanc migration, `desktop-bridge-release.yml` creates a missing pointer or verifies that an existing pointer has exactly the expected digest. A different existing digest stops the bridge. -The Product pointer is copied byte-for-byte from the bridge candidate's accepted -`product-channel.json`. The Desktop pointer is copied byte-for-byte from +The Product legacy pointer is copied byte-for-byte from the bridge candidate's accepted +`product-channel.json`. The next accepted Product promotion bootstraps `product-index.json` from that +pointer and its new candidate; run that Product promotion before the first index-based Desktop +publication. The Desktop pointer is copied byte-for-byte from `desktop-channel-modern.json` and uploaded to `desktop-stable` under the canonical asset name `desktop-channel.json`. The bridge retains its legacy `desktop-channel.json` under the original name for already installed clients. @@ -123,6 +187,8 @@ After the hosted workflow succeeds, verify: - repository-wide latest still names the bridge tag; - `product-stable/product-channel.json` has the accepted digest and names the immutable Product tag; +- `product-stable/product-index.json` has a valid signature and contains every subsequently accepted + Product release exactly once; - `desktop-stable/desktop-channel.json` has the accepted modern-channel digest and names the bridge tag; - both native and WSL independent-feed acceptance reports were uploaded; diff --git a/package.json b/package.json index 83803486..fe53e1c4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "ci:test:scripts": "pnpm exec vitest run --config scripts/vitest.config.ts --environment node", "ci:test:workspace": "pnpm -r --filter './packages/**' --filter '!@coder-studio/web' --if-present run test && pnpm --filter @coder-studio/web test", "ci:test": "pnpm ci:test:scripts && pnpm ci:test:workspace", - "ci:typecheck:strict": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/desktop typecheck", + "ci:typecheck:strict": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/skill-manager exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/desktop typecheck", "ci:typecheck:web": "tsx scripts/check-typecheck-baseline.ts", "ci:typecheck": "pnpm ci:typecheck:strict && pnpm ci:typecheck:web", "ci:build": "pnpm --filter @coder-studio/server build && pnpm build", diff --git a/packages/core/package.json b/packages/core/package.json index 5c4c54b5..6361d133 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "@coder-studio/skill-manager": "workspace:*" + }, "peerDependencies": { "zod": "^4.4.2" }, diff --git a/packages/core/src/domain/desktop-preferences.ts b/packages/core/src/domain/desktop-preferences.ts new file mode 100644 index 00000000..5a7ad6c1 --- /dev/null +++ b/packages/core/src/domain/desktop-preferences.ts @@ -0,0 +1,27 @@ +export const DESKTOP_PREFERENCES_SCHEMA_VERSION = 1 as const; + +export interface DesktopPreferencesSnapshot { + schemaVersion: typeof DESKTOP_PREFERENCES_SCHEMA_VERSION; + revision: number; + updatedAt: string | null; + appearance: { + themeId: string | null; + }; +} + +export interface DesktopPreferencesPatch { + appearance?: { + themeId?: string; + }; +} + +export function createDefaultDesktopPreferences(): DesktopPreferencesSnapshot { + return { + schemaVersion: DESKTOP_PREFERENCES_SCHEMA_VERSION, + revision: 0, + updatedAt: null, + appearance: { + themeId: null, + }, + }; +} diff --git a/packages/core/src/domain/skill-management.ts b/packages/core/src/domain/skill-management.ts index 5658374c..c03dc3e4 100644 --- a/packages/core/src/domain/skill-management.ts +++ b/packages/core/src/domain/skill-management.ts @@ -1,56 +1,6 @@ -export const SKILL_INSTALL_STATES = ["installed", "installing", "failed"] as const; -export const SKILL_LIBRARY_SOURCES = ["builtin", "installed", "custom"] as const; -export const SKILL_LIBRARY_ORIGINS = ["builtin", "skillhub", "filesystem"] as const; -export const SKILL_LIBRARY_ITEM_STATES = [ - "unmounted", - "partially_mounted", - "fully_mounted", - "error", -] as const; -export const SKILL_MOUNT_STATUSES = [ - "mounted", - "stale", - "missing_target", - "missing_source", - "failed", -] as const; -export const SKILL_VERSION_CHECK_STATUSES = [ - "up_to_date", - "update_available", - "unknown", - "error", -] as const; -export const SKILL_TARGET_HEALTH_STATES = ["healthy", "warning", "error", "unconfigured"] as const; - -type SkillInstallState = (typeof SKILL_INSTALL_STATES)[number]; -export type SkillLibrarySource = (typeof SKILL_LIBRARY_SOURCES)[number]; -export type SkillLibraryOrigin = (typeof SKILL_LIBRARY_ORIGINS)[number]; -type SkillMountStatus = (typeof SKILL_MOUNT_STATUSES)[number]; -export type SkillVersionCheckStatus = (typeof SKILL_VERSION_CHECK_STATUSES)[number]; -type SkillTargetHealthState = (typeof SKILL_TARGET_HEALTH_STATES)[number]; - -export function isSkillMountStatus(value: string): value is SkillMountStatus { - return (SKILL_MOUNT_STATUSES as readonly string[]).includes(value); -} - -export interface SkillLibraryEntry { - slug: string; - displayName: string; - description?: string; - version: string; - source: SkillLibrarySource; - origin?: SkillLibraryOrigin; - libraryPath: string; - installState: SkillInstallState; - installedAt: number; - updatedAt: number; - lastError?: string; - builtin?: { - defaultEnabled: boolean; - autoMount: boolean; - }; -} +export * from "@coder-studio/skill-manager"; +/** Coder Studio workspace-intelligence extension; not part of the reusable Skill Manager. */ export interface SkillRecommendationEntry { slug: string; displayName: string; @@ -65,68 +15,3 @@ export interface SkillRecommendationPage { entries: SkillRecommendationEntry[]; hasMore: boolean; } - -export interface SkillVersionCheckEntry { - slug: string; - currentVersion: string; - latestVersion?: string; - status: SkillVersionCheckStatus; - error?: string; -} - -export interface AgentSkillTargetEntry { - providerId: string; - displayName: string; - kind: "built_in" | "preset" | "custom"; - skillDir?: string; - mountPreference: "auto"; - lastHealthState: SkillTargetHealthState; - lastHealthError?: string; -} - -export interface SkillMountRelation { - providerId: string; - skillSlug: string; - enabled: boolean; - sourcePath: string; - targetPath: string; - mountModeResolved: "symlink" | "copy"; - status: SkillMountStatus; - lastSyncedAt?: number; - lastError?: string; -} - -export interface SkillInstallStepSnapshot { - id: string; - titleKey: string; - kind: "prepare" | "download" | "extract" | "verify"; - status: "pending" | "running" | "succeeded" | "failed"; - detail?: string; - startedAt?: number; - finishedAt?: number; -} - -export interface SkillInstallFailure { - code: - | "cli_unavailable" - | "search_parse_failed" - | "install_failed" - | "sync_failed" - | "invalid_skill_payload" - | "write_failed" - | "unknown_failure"; - slug: string; - failedStepId: string; - message: string; - detail?: string; -} - -export interface SkillInstallJobSnapshot { - jobId: string; - slug: string; - version?: string; - status: "queued" | "running" | "succeeded" | "failed"; - currentStepId?: string; - steps: SkillInstallStepSnapshot[]; - failure?: SkillInstallFailure; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 758ad834..2232ad90 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./domain/automation"; export * from "./domain/canvas"; +export * from "./domain/desktop-preferences"; export * from "./domain/diagnostics"; export * from "./domain/events"; export * from "./domain/lsp"; diff --git a/packages/desktop/src/desktop-preferences-ipc.test.ts b/packages/desktop/src/desktop-preferences-ipc.test.ts new file mode 100644 index 00000000..e96de359 --- /dev/null +++ b/packages/desktop/src/desktop-preferences-ipc.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerDesktopPreferencesIpc } from "./desktop-preferences-ipc.js"; + +describe("Desktop preferences IPC", () => { + it("registers read, migration, and update handlers with validated values", async () => { + const handlers = new Map unknown>(); + const ipc = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }), + }; + const snapshot = { + schemaVersion: 1 as const, + revision: 2, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "nord-dark" }, + }; + const store = { + getSnapshot: vi.fn(() => snapshot), + initializeTheme: vi.fn(async () => snapshot), + update: vi.fn(async () => snapshot), + }; + registerDesktopPreferencesIpc({ ipc: ipc as never, getStore: () => store }); + + expect([...handlers.keys()]).toEqual([ + "desktop:get-preferences", + "desktop:initialize-theme-preference", + "desktop:update-preferences", + ]); + expect(handlers.get("desktop:get-preferences")?.({})).toEqual(snapshot); + await expect( + handlers.get("desktop:initialize-theme-preference")?.({}, "mint-dark") + ).resolves.toEqual(snapshot); + await expect( + handlers.get("desktop:update-preferences")?.( + {}, + { + appearance: { themeId: "mint-light" }, + } + ) + ).resolves.toEqual(snapshot); + expect(store.initializeTheme).toHaveBeenCalledWith("mint-dark"); + expect(store.update).toHaveBeenCalledWith({ appearance: { themeId: "mint-light" } }); + }); + + it("rejects malformed renderer input before it reaches the store", () => { + const handlers = new Map unknown>(); + const ipc = { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }, + }; + const store = { + getSnapshot: vi.fn(), + initializeTheme: vi.fn(), + update: vi.fn(), + }; + registerDesktopPreferencesIpc({ ipc, getStore: () => store as never }); + + expect(() => handlers.get("desktop:initialize-theme-preference")?.({}, "bad\nvalue")).toThrow( + "Invalid Desktop theme id" + ); + expect(() => handlers.get("desktop:update-preferences")?.({}, {})).toThrow( + "no supported values" + ); + expect(store.initializeTheme).not.toHaveBeenCalled(); + expect(store.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/src/desktop-preferences-ipc.ts b/packages/desktop/src/desktop-preferences-ipc.ts new file mode 100644 index 00000000..4f70b287 --- /dev/null +++ b/packages/desktop/src/desktop-preferences-ipc.ts @@ -0,0 +1,62 @@ +import type { DesktopPreferencesPatch, DesktopPreferencesSnapshot } from "@coder-studio/core"; + +interface DesktopPreferencesStorePort { + getSnapshot(): DesktopPreferencesSnapshot; + initializeTheme(themeId: string): Promise; + update(patch: DesktopPreferencesPatch): Promise; +} + +interface IpcRegistrar { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; +} + +export interface RegisterDesktopPreferencesIpcOptions { + ipc: IpcRegistrar; + getStore: () => DesktopPreferencesStorePort | null; +} + +function readThemeId(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 256 || + value.trim() !== value || + /[\0\r\n]/.test(value) + ) { + throw new Error("Invalid Desktop theme id"); + } + return value; +} + +function readPatch(value: unknown): DesktopPreferencesPatch { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Desktop preferences patch"); + } + const appearance = (value as { appearance?: unknown }).appearance; + if (!appearance || typeof appearance !== "object" || Array.isArray(appearance)) { + throw new Error("Desktop preferences patch has no supported values"); + } + return { + appearance: { + themeId: readThemeId((appearance as { themeId?: unknown }).themeId), + }, + }; +} + +function requireStore( + getStore: RegisterDesktopPreferencesIpcOptions["getStore"] +): DesktopPreferencesStorePort { + const store = getStore(); + if (!store) throw new Error("Desktop preferences are not initialized"); + return store; +} + +export function registerDesktopPreferencesIpc(options: RegisterDesktopPreferencesIpcOptions): void { + options.ipc.handle("desktop:get-preferences", () => requireStore(options.getStore).getSnapshot()); + options.ipc.handle("desktop:initialize-theme-preference", (_event, themeId) => + requireStore(options.getStore).initializeTheme(readThemeId(themeId)) + ); + options.ipc.handle("desktop:update-preferences", (_event, patch) => + requireStore(options.getStore).update(readPatch(patch)) + ); +} diff --git a/packages/desktop/src/desktop-preferences-store.test.ts b/packages/desktop/src/desktop-preferences-store.test.ts new file mode 100644 index 00000000..a2537f84 --- /dev/null +++ b/packages/desktop/src/desktop-preferences-store.test.ts @@ -0,0 +1,200 @@ +import { EventEmitter } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DesktopPreferencesStore } from "./desktop-preferences-store.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); + vi.useRealTimers(); +}); + +async function createFilePath(): Promise { + const root = await mkdtemp(join(tmpdir(), "desktop-preferences-")); + tempDirs.push(root); + return join(root, "desktop-preferences.json"); +} + +class FakeWatcher extends EventEmitter { + readonly close = vi.fn(); +} + +describe("DesktopPreferencesStore", () => { + it("starts empty, initializes the theme once, and persists atomic snapshots", async () => { + const filePath = await createFilePath(); + const changed = vi.fn(); + const store = new DesktopPreferencesStore({ + filePath, + now: () => new Date("2026-08-26T12:30:00.000Z"), + onChanged: changed, + }); + + await expect(store.start()).resolves.toMatchObject({ + revision: 0, + appearance: { themeId: null }, + }); + await expect(store.initializeTheme("nord-dark")).resolves.toEqual({ + schemaVersion: 1, + revision: 1, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "nord-dark" }, + }); + await expect(store.initializeTheme("mint-light")).resolves.toMatchObject({ + revision: 1, + appearance: { themeId: "nord-dark" }, + }); + + expect(JSON.parse(await readFile(filePath, "utf8"))).toMatchObject({ + revision: 1, + appearance: { themeId: "nord-dark" }, + }); + expect(changed).toHaveBeenCalledTimes(1); + store.close(); + }); + + it("serializes concurrent writers and keeps the revision monotonic", async () => { + const filePath = await createFilePath(); + const native = new DesktopPreferencesStore({ filePath }); + const wsl = new DesktopPreferencesStore({ filePath }); + await Promise.all([native.start(), wsl.start()]); + + await Promise.all([ + native.update({ appearance: { themeId: "graphite-dark" } }), + wsl.update({ appearance: { themeId: "mint-light" } }), + ]); + await Promise.all([native.refresh(), wsl.refresh()]); + + const persisted = JSON.parse(await readFile(filePath, "utf8")); + expect(persisted.revision).toBe(2); + expect(["graphite-dark", "mint-light"]).toContain(persisted.appearance.themeId); + expect(native.getSnapshot()).toEqual(wsl.getSnapshot()); + native.close(); + wsl.close(); + }); + + it("rejects a compromised lock before writing preferences", async () => { + const filePath = await createFilePath(); + const warnings: string[] = []; + const store = new DesktopPreferencesStore({ + filePath, + lockFile: async (_path, options) => { + options?.onCompromised?.(new Error("lock ownership lost")); + return async () => { + throw new Error("lock already released"); + }; + }, + onWarning: (message) => warnings.push(message), + }); + await store.start(); + + await expect(store.update({ appearance: { themeId: "graphite-dark" } })).rejects.toThrow( + "Desktop preferences lock was compromised: lock ownership lost" + ); + await expect(readFile(filePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(warnings).toContain("Desktop preferences lock was compromised: lock ownership lost"); + expect(warnings).toContain("Unable to release Desktop preferences lock: lock already released"); + store.close(); + }); + + it("does not report a committed preference write as failed when lock release fails", async () => { + const filePath = await createFilePath(); + const warnings: string[] = []; + const store = new DesktopPreferencesStore({ + filePath, + lockFile: async () => async () => { + throw new Error("unable to remove lock directory"); + }, + onWarning: (message) => warnings.push(message), + }); + await store.start(); + + await expect(store.update({ appearance: { themeId: "graphite-dark" } })).resolves.toMatchObject( + { + revision: 1, + appearance: { themeId: "graphite-dark" }, + } + ); + expect(JSON.parse(await readFile(filePath, "utf8"))).toMatchObject({ + revision: 1, + appearance: { themeId: "graphite-dark" }, + }); + expect(warnings).toContain( + "Unable to release Desktop preferences lock: unable to remove lock directory" + ); + store.close(); + }); + + it("allows only the first concurrent migration to initialize the theme", async () => { + const filePath = await createFilePath(); + const native = new DesktopPreferencesStore({ filePath }); + const wsl = new DesktopPreferencesStore({ filePath }); + await Promise.all([native.start(), wsl.start()]); + + await Promise.all([native.initializeTheme("nord-dark"), wsl.initializeTheme("mint-light")]); + await Promise.all([native.refresh(), wsl.refresh()]); + + expect(native.getSnapshot().revision).toBe(1); + expect(native.getSnapshot()).toEqual(wsl.getSnapshot()); + native.close(); + wsl.close(); + }); + + it("reloads a sibling write after a matching directory watch event", async () => { + const filePath = await createFilePath(); + const watcher = new FakeWatcher(); + let onDirectoryChange: + | ((eventType: string, filename: string | Buffer | null) => void) + | undefined; + const changed = vi.fn(); + const reader = new DesktopPreferencesStore({ + filePath, + onChanged: changed, + watchDebounceMs: 25, + watchDirectory: (_path, listener) => { + onDirectoryChange = listener; + return watcher; + }, + }); + const writer = new DesktopPreferencesStore({ filePath }); + await Promise.all([reader.start(), writer.start()]); + await writer.update({ appearance: { themeId: "winter-dark" } }); + + onDirectoryChange?.("rename", "unrelated.json"); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(changed).not.toHaveBeenCalled(); + onDirectoryChange?.("rename", "desktop-preferences.json"); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ revision: 1, appearance: { themeId: "winter-dark" } }) + ); + reader.close(); + writer.close(); + expect(watcher.close).toHaveBeenCalledTimes(1); + }); + + it("keeps the last valid snapshot when the shared file becomes malformed", async () => { + const filePath = await createFilePath(); + const warnings: string[] = []; + const store = new DesktopPreferencesStore({ + filePath, + onWarning: (message) => warnings.push(message), + }); + await store.start(); + await store.update({ appearance: { themeId: "hc-dark" } }); + await writeFile(filePath, "{invalid", "utf8"); + + await expect(store.refresh()).resolves.toMatchObject({ + revision: 1, + appearance: { themeId: "hc-dark" }, + }); + await expect(store.update({ appearance: { themeId: "hc-light" } })).rejects.toThrow( + "Unable to parse desktop-preferences.json" + ); + expect(warnings).toHaveLength(1); + store.close(); + }); +}); diff --git a/packages/desktop/src/desktop-preferences-store.ts b/packages/desktop/src/desktop-preferences-store.ts new file mode 100644 index 00000000..26363bb8 --- /dev/null +++ b/packages/desktop/src/desktop-preferences-store.ts @@ -0,0 +1,295 @@ +import { watch } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { basename, dirname } from "node:path"; +import { + createDefaultDesktopPreferences, + DESKTOP_PREFERENCES_SCHEMA_VERSION, + type DesktopPreferencesPatch, + type DesktopPreferencesSnapshot, +} from "@coder-studio/core"; +import { lock } from "proper-lockfile"; +import { writeJsonFileAtomic } from "./atomic-json-file.js"; + +const DEFAULT_WATCH_DEBOUNCE_MS = 75; +const PREFERENCES_LOCK_STALE_MS = 10_000; + +interface DirectoryWatcher { + close(): void; + on(event: "error", listener: (error: Error) => void): DirectoryWatcher; +} +type WatchDirectory = ( + path: string, + listener: (eventType: string, filename: string | Buffer | null) => void +) => DirectoryWatcher; + +export interface DesktopPreferencesStoreOptions { + filePath: string; + lockFile?: typeof lock; + now?: () => Date; + onChanged?: (snapshot: DesktopPreferencesSnapshot) => void; + onWarning?: (message: string) => void; + watchDirectory?: WatchDirectory; + watchDebounceMs?: number; +} + +type DiskSnapshotResult = + | { kind: "missing" } + | { kind: "invalid"; message: string } + | { kind: "valid"; snapshot: DesktopPreferencesSnapshot }; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isValidThemeId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 256 && + value.trim() === value && + !/[\0\r\n]/.test(value) + ); +} + +function parseSnapshot(value: unknown): DesktopPreferencesSnapshot | null { + if (!isRecord(value) || value.schemaVersion !== DESKTOP_PREFERENCES_SCHEMA_VERSION) return null; + if (!Number.isInteger(value.revision) || (value.revision as number) < 0) return null; + if (value.updatedAt !== null && typeof value.updatedAt !== "string") return null; + if (!isRecord(value.appearance)) return null; + const themeId = value.appearance.themeId; + if (themeId !== null && !isValidThemeId(themeId)) return null; + return { + schemaVersion: DESKTOP_PREFERENCES_SCHEMA_VERSION, + revision: value.revision as number, + updatedAt: value.updatedAt as string | null, + appearance: { themeId }, + }; +} + +function cloneSnapshot(snapshot: DesktopPreferencesSnapshot): DesktopPreferencesSnapshot { + return { + ...snapshot, + appearance: { ...snapshot.appearance }, + }; +} + +function snapshotsEqual( + left: DesktopPreferencesSnapshot, + right: DesktopPreferencesSnapshot +): boolean { + return ( + left.revision === right.revision && + left.updatedAt === right.updatedAt && + left.appearance.themeId === right.appearance.themeId + ); +} + +export class DesktopPreferencesStore { + private snapshot = createDefaultDesktopPreferences(); + private watcher: DirectoryWatcher | null = null; + private reloadTimer: NodeJS.Timeout | null = null; + private started = false; + private closed = false; + private lastWarning: string | null = null; + + constructor(private readonly options: DesktopPreferencesStoreOptions) {} + + async start(): Promise { + if (this.started) return this.getSnapshot(); + if (this.closed) throw new Error("Desktop preferences store is closed"); + this.started = true; + await mkdir(dirname(this.options.filePath), { recursive: true }); + const initial = await this.readDiskSnapshot(); + if (initial.kind === "valid") this.snapshot = initial.snapshot; + if (initial.kind === "invalid") this.warnOnce(initial.message); + + const watchDirectory = + this.options.watchDirectory ?? + ((path, listener) => watch(path, { persistent: false }, listener)); + this.watcher = watchDirectory(dirname(this.options.filePath), (_eventType, filename) => { + if (filename !== null && basename(filename.toString()) !== basename(this.options.filePath)) { + return; + } + this.scheduleReload(); + }); + this.watcher.on("error", (error) => { + this.options.onWarning?.( + `Desktop preferences watcher failed: ${error instanceof Error ? error.message : String(error)}` + ); + }); + + // Close the read-to-watch race: a sibling process may write after the first read but before + // the directory watcher is attached. + await this.refresh(); + return this.getSnapshot(); + } + + getSnapshot(): DesktopPreferencesSnapshot { + return cloneSnapshot(this.snapshot); + } + + async initializeTheme(themeId: string): Promise { + this.assertThemeId(themeId); + return this.mutate((current) => + current.appearance.themeId === null + ? { + ...current, + appearance: { ...current.appearance, themeId }, + } + : current + ); + } + + async update(patch: DesktopPreferencesPatch): Promise { + const themeId = patch.appearance?.themeId; + if (themeId === undefined) throw new Error("Desktop preferences patch has no supported values"); + this.assertThemeId(themeId); + return this.mutate((current) => ({ + ...current, + appearance: { ...current.appearance, themeId }, + })); + } + + async refresh(): Promise { + if (this.closed) return this.getSnapshot(); + const result = await this.readDiskSnapshot(); + if (result.kind === "invalid") { + this.warnOnce(result.message); + return this.getSnapshot(); + } + if (result.kind === "missing") return this.getSnapshot(); + this.lastWarning = null; + if (result.snapshot.revision > this.snapshot.revision) { + this.accept(result.snapshot, true); + } + return this.getSnapshot(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.reloadTimer) clearTimeout(this.reloadTimer); + this.reloadTimer = null; + this.watcher?.close(); + this.watcher = null; + } + + private async mutate( + apply: (current: DesktopPreferencesSnapshot) => DesktopPreferencesSnapshot + ): Promise { + if (this.closed) throw new Error("Desktop preferences store is closed"); + await mkdir(dirname(this.options.filePath), { recursive: true }); + let compromisedError: Error | null = null; + const release = await (this.options.lockFile ?? lock)(this.options.filePath, { + realpath: false, + stale: PREFERENCES_LOCK_STALE_MS, + retries: { + retries: 8, + factor: 1.5, + minTimeout: 10, + maxTimeout: 100, + randomize: true, + }, + onCompromised: (error) => { + compromisedError = error; + this.options.onWarning?.(`Desktop preferences lock was compromised: ${error.message}`); + }, + }); + try { + this.assertLockHealthy(compromisedError); + const disk = await this.readDiskSnapshot(); + this.assertLockHealthy(compromisedError); + if (disk.kind === "invalid") throw new Error(disk.message); + const current = disk.kind === "valid" ? disk.snapshot : createDefaultDesktopPreferences(); + const candidate = apply(current); + if (snapshotsEqual(candidate, current)) { + this.assertLockHealthy(compromisedError); + this.accept(current, current.revision > this.snapshot.revision); + return this.getSnapshot(); + } + const next: DesktopPreferencesSnapshot = { + ...candidate, + schemaVersion: DESKTOP_PREFERENCES_SCHEMA_VERSION, + revision: current.revision + 1, + updatedAt: (this.options.now ?? (() => new Date()))().toISOString(), + appearance: { ...candidate.appearance }, + }; + this.assertLockHealthy(compromisedError); + await writeJsonFileAtomic(this.options.filePath, next); + this.lastWarning = null; + this.accept(next, true); + return this.getSnapshot(); + } finally { + await release().catch((error) => { + this.options.onWarning?.( + `Unable to release Desktop preferences lock: ${error instanceof Error ? error.message : String(error)}` + ); + }); + } + } + + private assertLockHealthy(error: Error | null): void { + if (!error) return; + throw new Error(`Desktop preferences lock was compromised: ${error.message}`); + } + + private accept(snapshot: DesktopPreferencesSnapshot, notify: boolean): void { + if (snapshotsEqual(snapshot, this.snapshot)) return; + this.snapshot = cloneSnapshot(snapshot); + if (!notify) return; + try { + this.options.onChanged?.(this.getSnapshot()); + } catch (error) { + this.options.onWarning?.( + `Desktop preferences listener failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private scheduleReload(): void { + if (this.closed) return; + if (this.reloadTimer) clearTimeout(this.reloadTimer); + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + void this.refresh().catch((error) => { + this.options.onWarning?.( + `Unable to reload Desktop preferences: ${error instanceof Error ? error.message : String(error)}` + ); + }); + }, this.options.watchDebounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS); + this.reloadTimer.unref?.(); + } + + private async readDiskSnapshot(): Promise { + let serialized: string; + try { + serialized = await readFile(this.options.filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" }; + throw error; + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch (error) { + return { + kind: "invalid", + message: `Unable to parse desktop-preferences.json: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const snapshot = parseSnapshot(value); + return snapshot + ? { kind: "valid", snapshot } + : { kind: "invalid", message: "desktop-preferences.json has an unsupported format" }; + } + + private assertThemeId(themeId: string): void { + if (!isValidThemeId(themeId)) throw new Error("Invalid Desktop theme id"); + } + + private warnOnce(message: string): void { + if (this.lastWarning === message) return; + this.lastWarning = message; + this.options.onWarning?.(message); + } +} diff --git a/packages/desktop/src/desktop-update-coordinator.test.ts b/packages/desktop/src/desktop-update-coordinator.test.ts index 0ad62774..af205343 100644 --- a/packages/desktop/src/desktop-update-coordinator.test.ts +++ b/packages/desktop/src/desktop-update-coordinator.test.ts @@ -9,6 +9,7 @@ import type { DesktopChannel } from "./desktop-channel.js"; import { DesktopUpdateCoordinator } from "./desktop-update-coordinator.js"; import { DesktopUpdateJournal } from "./desktop-update-journal.js"; import type { ProductChannel } from "./product-channel.js"; +import type { ProductIndex } from "./product-index.js"; import type { RuntimeManifestV2 } from "./runtime-manifest.js"; import type { RuntimeDownloadOptions, RuntimeUpdateMetadata } from "./runtime-update-manager.js"; @@ -419,8 +420,61 @@ describe("DesktopUpdateCoordinator", () => { await expect(coordinator.check({ manual: true })).resolves.toMatchObject({ status: "failed", - compatibility: { compatible: false, code: "runtime_host_incompatible" }, + diagnostics: { + productChannelError: "No accepted Product Runtime is compatible with Desktop Shell 0.3.0", + }, + }); + }); + + it("skips an incompatible latest Runtime and selects the next accepted compatible release", async () => { + const { coordinator, loadProductChannel, runtime } = createHarness({ + shellVersion: "0.3.0", + runtimeVersion: "0.5.0", + }); + const compatible = { + version: productChannel.version, + releaseTag: productChannel.releaseTag, + publishedAt: productChannel.runtimes["win32-x64"].publishedAt, + minShellVersion: productChannel.minShellVersion, + requirements: productChannel.requirements, + runtimes: { + "win32-x64": { + manifest: productChannel.runtimes["win32-x64"].manifest, + manifestSha256: productChannel.runtimes["win32-x64"].manifestSha256, + }, + "linux-x64": { + manifest: productChannel.runtimes["linux-x64"].manifest, + manifestSha256: productChannel.runtimes["linux-x64"].manifestSha256, + }, + }, + }; + const index: ProductIndex = { + schemaVersion: 1, + channel: "product-index", + generatedAt: "2026-08-09T01:02:03.000Z", + latestVersion: "0.7.0", + releases: [ + compatible, + { + ...compatible, + version: "0.7.0", + releaseTag: "v0.7.0", + requirements: { ...compatible.requirements, engineVersion: "3" }, + }, + ], + signature: { algorithm: "ed25519", value: "signed" }, + }; + loadProductChannel.mockResolvedValueOnce(index as never); + + await expect(coordinator.check({ manual: true })).resolves.toMatchObject({ + status: "available", + components: [expect.objectContaining({ id: "runtime:win32-x64", targetVersion: "0.6.0" })], }); + expect(runtime.checkMetadata).toHaveBeenCalledWith( + expect.objectContaining({ version: "0.6.0" }), + "0.3.0", + "v0.6.0" + ); }); it("validates a Shell-only plan against the installed Runtime manifest", async () => { diff --git a/packages/desktop/src/desktop-update-coordinator.ts b/packages/desktop/src/desktop-update-coordinator.ts index b1474d24..9142bcdf 100644 --- a/packages/desktop/src/desktop-update-coordinator.ts +++ b/packages/desktop/src/desktop-update-coordinator.ts @@ -10,7 +10,12 @@ import type { DesktopBuildInfo } from "./build-info.js"; import type { DesktopChannel } from "./desktop-channel.js"; import type { DesktopUpdateJournal, DesktopUpdateJournalRecord } from "./desktop-update-journal.js"; import type { DesktopUpdateSettingsRepo } from "./desktop-update-settings.js"; -import type { ProductChannel, ProductChannelRuntime } from "./product-channel.js"; +import type { ProductChannelRuntime, ProductRelease } from "./product-channel.js"; +import { + type ProductCompatibilityHost, + type ProductReleaseSource, + selectHighestCompatibleProductRelease, +} from "./product-index.js"; import { compareVersions, type RuntimeManifest } from "./runtime-manifest.js"; import type { RuntimeUpdateAdapter, RuntimeUpdateMetadata } from "./runtime-update-manager.js"; import type { DesktopShellUpdateAdapter, ShellUpdateMetadata } from "./update-manager.js"; @@ -23,7 +28,7 @@ export interface DesktopUpdateCoordinatorDeps { currentSharedWebVersion: () => string; currentProductPublishedAt: () => string | null; getBuildInfo: () => DesktopBuildInfo; - loadProductChannel: () => Promise; + loadProductChannel: () => Promise; loadDesktopChannel: () => Promise; shell: DesktopShellUpdateAdapter; getRuntimeAdapter: ( @@ -180,7 +185,8 @@ export class DesktopUpdateCoordinator { productResult.status === "rejected" ? errorMessage(productResult.reason) : null; let desktopChannelError = desktopResult.status === "rejected" ? errorMessage(desktopResult.reason) : null; - let productChannel = productResult.status === "fulfilled" ? productResult.value : null; + const productSource = productResult.status === "fulfilled" ? productResult.value : null; + let productChannel: ProductRelease | null = null; let desktopChannel = desktopResult.status === "fulfilled" ? desktopResult.value : null; const buildInfo = this.deps.getBuildInfo(); let shellMetadata: ShellUpdateMetadata | null = null; @@ -198,6 +204,26 @@ export class DesktopUpdateCoordinator { desktopChannel && compareVersions(desktopChannel.shell.version, buildInfo.shellVersion) > 0 ? desktopChannel.shell.version : buildInfo.shellVersion; + if (productSource) { + try { + const plannedHost = this.productCompatibilityHost( + desktopChannel && + compareVersions(desktopChannel.shell.version, buildInfo.shellVersion) > 0 + ? desktopChannel.shell + : buildInfo + ); + const selected = selectHighestCompatibleProductRelease(productSource, plannedHost); + if (!selected) { + throw new Error( + `No accepted Product Runtime is compatible with Desktop Shell ${plannedHost.shellVersion}` + ); + } + productChannel = selected; + } catch (error) { + productChannelError = errorMessage(error); + productChannel = null; + } + } let checkedRuntimes = await Promise.all( this.runtimeTargetsForCheck().map(async (target): Promise => { const adapter = await this.deps.getRuntimeAdapter( @@ -462,11 +488,18 @@ export class DesktopUpdateCoordinator { this.deps.getRuntimeAdapter(target, environmentId), ]); const buildInfo = this.deps.getBuildInfo(); - const expected = channel.runtimes[target]; + const host = this.productCompatibilityHost(buildInfo); + const selected = selectHighestCompatibleProductRelease(channel, host); + if (!selected) { + throw new Error( + `No accepted Product Runtime is compatible with Desktop Shell ${host.shellVersion}` + ); + } + const expected = selected.runtimes[target]; const metadata = await adapter.checkMetadata( expected, buildInfo.shellVersion, - channel.releaseTag + selected.releaseTag ); const currentVersion = await adapter.getCurrentVersion(); this.assertNotStopping(); @@ -862,7 +895,7 @@ export class DesktopUpdateCoordinator { } private createNeededComponents( - productChannel: ProductChannel | null, + productChannel: ProductRelease | null, buildInfo: DesktopBuildInfo, shellMetadata: ShellUpdateMetadata | null, checkedRuntimes: CheckedRuntime[] @@ -911,7 +944,7 @@ export class DesktopUpdateCoordinator { } private validatePlan( - productChannel: ProductChannel | null, + productChannel: ProductRelease | null, desktopChannel: DesktopChannel | null, buildInfo: DesktopBuildInfo, components: ProductUpdateComponent[], @@ -988,6 +1021,29 @@ export class DesktopUpdateCoordinator { return { compatible: true, code: null, summary: null }; } + private productCompatibilityHost( + source: DesktopBuildInfo | DesktopChannel["shell"] + ): ProductCompatibilityHost { + const shellVersion = "shellVersion" in source ? source.shellVersion : source.version; + if ( + !source.engineVersion || + !source.nodeVersion || + !source.runtimeHostApiVersion || + !source.apiProtocolVersion || + !source.dataSchemaVersion + ) { + throw new Error(`Desktop Shell ${shellVersion} has incomplete Runtime capabilities`); + } + return { + shellVersion, + engineVersion: source.engineVersion, + nodeVersion: source.nodeVersion, + runtimeHostApiVersion: source.runtimeHostApiVersion, + apiProtocolVersion: source.apiProtocolVersion, + dataSchemaVersion: source.dataSchemaVersion, + }; + } + private assertSourceMatchesShell(channel: DesktopChannel, shell: ShellUpdateMetadata): void { if ( shell.version !== channel.shell.version || diff --git a/packages/desktop/src/environment-manager.ts b/packages/desktop/src/environment-manager.ts index 2f5cc63b..64a630bc 100644 --- a/packages/desktop/src/environment-manager.ts +++ b/packages/desktop/src/environment-manager.ts @@ -1,9 +1,6 @@ import { EnvironmentStateStore, NATIVE_ENVIRONMENT } from "./environment-state.js"; -import type { - FactoryProductProvenance, - ProductChannel, - ProductChannelRuntime, -} from "./product-channel.js"; +import type { FactoryProductProvenance, ProductChannelRuntime } from "./product-channel.js"; +import { listCompatibleProductReleases, type ProductReleaseSource } from "./product-index.js"; import type { DesktopEnvironmentProgress, DesktopEnvironmentSummary, @@ -40,7 +37,7 @@ export interface DesktopEnvironmentManagerOptions { releaseBaseUrl: string; factoryReleaseBaseUrl?: string; productChannelUrl?: string; - loadProductChannel?: () => Promise; + loadProductChannel?: () => Promise; factoryProduct?: FactoryProductProvenance; enableWsl?: boolean; fetch?: typeof fetch; @@ -160,7 +157,7 @@ export class DesktopEnvironmentManager { onProgress: (progress) => this.emit(target.id, progress.phase, progress.message, progress.percent), }); - let channel: ProductChannel | null = null; + let channel: ProductReleaseSource | null = null; if (this.options.loadProductChannel) { try { channel = await this.options.loadProductChannel(); @@ -173,9 +170,19 @@ export class DesktopEnvironmentManager { | (Omit & { publishedAt?: string }) | null = null; let releaseTag: string | null = null; - if (channel?.version === this.options.runtimeVersion) { - expected = channel.runtimes["linux-x64"]; - releaseTag = channel.releaseTag; + const acceptedRelease = channel + ? listCompatibleProductReleases(channel, { + shellVersion: this.options.shellVersion, + engineVersion: DESKTOP_ENGINE_VERSION, + nodeVersion: this.options.nodeVersion, + runtimeHostApiVersion: RUNTIME_HOST_API_VERSION, + apiProtocolVersion: API_PROTOCOL_VERSION, + dataSchemaVersion: DATA_SCHEMA_VERSION, + }).find((release) => release.version === this.options.runtimeVersion) + : null; + if (acceptedRelease) { + expected = acceptedRelease.runtimes["linux-x64"]; + releaseTag = acceptedRelease.releaseTag; } else if (this.options.factoryProduct?.version === this.options.runtimeVersion) { expected = { ...this.options.factoryProduct.runtimes["linux-x64"], diff --git a/packages/desktop/src/main-update-wiring.test.ts b/packages/desktop/src/main-update-wiring.test.ts index fbcb95d4..24b3b34f 100644 --- a/packages/desktop/src/main-update-wiring.test.ts +++ b/packages/desktop/src/main-update-wiring.test.ts @@ -86,6 +86,11 @@ describe("unified Desktop update IPC", () => { const source = await readFile(resolve(import.meta.dirname, "main.ts"), "utf8"); expect(source).toContain("parseProductChannel"); + expect(source).toContain("parseProductIndex"); + expect(source).toContain("resolveProductIndexUrl(productChannelUrl)"); + expect(source).toContain( + "const productSourceUrl = acceptanceChannel ? productChannelUrl : productIndexUrl" + ); expect(source).toContain("productChannelUrl: productChannelUrl"); expect(source).toContain("desktopChannelUrl: desktopChannelUrl"); expect(source).toContain("loadProductChannel: loadProductChannel"); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index ae011df6..544af550 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -5,6 +5,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createDefaultProductUpdateState, + type DesktopPreferencesSnapshot, type ProductUpdateState, type UpdateRuntimeContext, } from "@coder-studio/core"; @@ -39,6 +40,8 @@ import { installDesktopContextMenu } from "./desktop-context-menu.js"; import { activateDesktopNotificationTarget } from "./desktop-notification-activation.js"; import { registerDesktopNotificationIpc } from "./desktop-notification-ipc.js"; import { createDesktopNotificationService } from "./desktop-notifications.js"; +import { registerDesktopPreferencesIpc } from "./desktop-preferences-ipc.js"; +import { DesktopPreferencesStore } from "./desktop-preferences-store.js"; import { DesktopUpdateCoordinator } from "./desktop-update-coordinator.js"; import { registerDesktopUpdateIpc, toLegacyRuntimeUpdateState } from "./desktop-update-ipc.js"; import { DesktopUpdateJournal } from "./desktop-update-journal.js"; @@ -66,6 +69,7 @@ import { parseProductChannel, resolveProductChannelUrl, } from "./product-channel.js"; +import { parseProductIndex, resolveProductIndexUrl } from "./product-index.js"; import type { DesktopEnvironmentProgress, DesktopEnvironmentTarget } from "./protocol.js"; import { resolveVersionedReleaseAsset } from "./release-channel.js"; import { DESKTOP_NODE_VERSION, getRuntimePublishedAt } from "./runtime-manifest.js"; @@ -104,6 +108,7 @@ let activeEnvironmentTarget: DesktopEnvironmentTarget = NATIVE_ENVIRONMENT; let environmentOpening = false; let environmentInstanceRoot: string | null = null; let environmentLaunchStore: EnvironmentLaunchStore | null = null; +let desktopPreferencesStore: DesktopPreferencesStore | null = null; let releaseProductRuntimeLease: (() => Promise) | null = null; let appOrigin: string | null = null; let shutdownComplete = false; @@ -230,6 +235,20 @@ function emitEnvironmentProgress(progress: DesktopEnvironmentProgress): void { mainWindow?.webContents.send("desktop:environment-progress", progress); } +function emitDesktopPreferencesChanged(snapshot: DesktopPreferencesSnapshot): void { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return; + mainWindow.webContents.send("desktop:preferences-changed", snapshot); +} + +function refreshDesktopPreferences(): void { + void desktopPreferencesStore?.refresh().catch((error) => { + console.warn( + "[desktop-preferences] Unable to refresh shared preferences", + error instanceof Error ? error.message : String(error) + ); + }); +} + async function openEnvironmentInstance( rootUserDataDir: string, target: DesktopEnvironmentTarget @@ -319,6 +338,10 @@ async function waitForUrl(url: string, timeoutMs = 20_000): Promise { function registerIpcHandlers(rootUserDataDir: string): void { registerDesktopNotificationIpc({ ipc: ipcMain, service: desktopNotifications }); + registerDesktopPreferencesIpc({ + ipc: ipcMain, + getStore: () => desktopPreferencesStore, + }); registerDesktopUpdateIpc({ ipc: ipcMain, getCoordinator: () => updateCoordinator, @@ -558,10 +581,12 @@ function createMainWindow(url: string, browserSession = activeSession): BrowserW ); }; window.on("focus", emitWindowActivityState); + window.on("focus", refreshDesktopPreferences); window.on("blur", emitWindowActivityState); window.on("minimize", emitWindowActivityState); window.on("restore", emitWindowActivityState); window.on("show", emitWindowActivityState); + window.on("show", refreshDesktopPreferences); window.on("hide", emitWindowActivityState); window.on("closed", () => { if (mainWindow === window) { @@ -641,6 +666,12 @@ async function startApplication(): Promise { environmentInstanceRoot = rootUserDataDir; environmentLaunchStore = new EnvironmentLaunchStore(rootUserDataDir); void environmentLaunchStore.cleanupStale(ENVIRONMENT_LAUNCH_MAX_AGE_MS).catch(() => undefined); + desktopPreferencesStore = new DesktopPreferencesStore({ + filePath: join(rootUserDataDir, "desktop-preferences.json"), + onChanged: emitDesktopPreferencesChanged, + onWarning: (message) => console.warn(`[desktop-preferences] ${message}`), + }); + await desktopPreferencesStore.start(); activeEnvironmentTarget = app.isPackaged ? readEnvironmentInstanceTarget(app.commandLine) : NATIVE_ENVIRONMENT; @@ -659,20 +690,26 @@ async function startApplication(): Promise { ? __CODER_STUDIO_PRODUCT_CHANNEL_URL__.trim() : ""; const productChannelUrl = resolveProductChannelUrl(process.env, compiledProductChannelUrl); + const productIndexUrl = productChannelUrl ? resolveProductIndexUrl(productChannelUrl) : ""; const compiledDesktopChannelUrl = typeof __CODER_STUDIO_DESKTOP_CHANNEL_URL__ === "string" ? __CODER_STUDIO_DESKTOP_CHANNEL_URL__.trim() : ""; const desktopChannelUrl = resolveDesktopChannelUrl(process.env, compiledDesktopChannelUrl); const loadProductChannel = async () => { - if (!productChannelUrl || !runtimePublicKey) { + const acceptanceChannel = process.env.CODER_STUDIO_DESKTOP_ACCEPTANCE === "1"; + const productSourceUrl = acceptanceChannel ? productChannelUrl : productIndexUrl; + if (!productSourceUrl || !runtimePublicKey) { throw new Error("Product update channel is not configured"); } - const response = await fetch(productChannelUrl, { cache: "no-store" }); + const response = await fetch(productSourceUrl, { cache: "no-store" }); if (!response.ok) { throw new Error(`Product update channel check failed with ${response.status}`); } - return parseProductChannel(await response.json(), runtimePublicKey, productChannelUrl); + const value = await response.json(); + return acceptanceChannel + ? parseProductChannel(value, runtimePublicKey, productSourceUrl) + : parseProductIndex(value, runtimePublicKey, productSourceUrl); }; const loadDesktopChannel = async () => { if (!desktopChannelUrl || !runtimePublicKey) { @@ -1001,6 +1038,7 @@ app.on("before-quit", (event: Event) => { if (shutdownComplete || shutdownStarted) return; event.preventDefault(); shutdownStarted = true; + desktopPreferencesStore?.close(); const activationFailurePromise = trackShutdownActivation( environmentActivation.failPending(ENVIRONMENT_SHUTDOWN_FAILURE_MESSAGE) ); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index f6c26fb7..ad843233 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,4 +1,9 @@ -import type { DesktopUpdateSettings, ProductUpdateState } from "@coder-studio/core"; +import type { + DesktopPreferencesPatch, + DesktopPreferencesSnapshot, + DesktopUpdateSettings, + ProductUpdateState, +} from "@coder-studio/core"; import { contextBridge, ipcRenderer } from "electron"; import type { DesktopApi, @@ -29,6 +34,7 @@ ipcRenderer.on( const api: DesktopApi = { platform: process.platform, + desktopPreferencesApiVersion: 1, updateApiVersion: 1, getAppVersion: () => ipcRenderer.invoke("desktop:get-app-version") as Promise, selectWorkspaceDirectory: () => ipcRenderer.invoke("desktop:select-workspace-directory"), @@ -78,6 +84,21 @@ const api: DesktopApi = { ipcRenderer.on("desktop:environment-progress", handler); return () => ipcRenderer.removeListener("desktop:environment-progress", handler); }, + getDesktopPreferences: () => + ipcRenderer.invoke("desktop:get-preferences") as Promise, + initializeDesktopTheme: (themeId: string) => + ipcRenderer.invoke( + "desktop:initialize-theme-preference", + themeId + ) as Promise, + updateDesktopPreferences: (patch: DesktopPreferencesPatch) => + ipcRenderer.invoke("desktop:update-preferences", patch) as Promise, + onDesktopPreferencesChanged: (listener: (snapshot: DesktopPreferencesSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: DesktopPreferencesSnapshot) => + listener(snapshot); + ipcRenderer.on("desktop:preferences-changed", handler); + return () => ipcRenderer.removeListener("desktop:preferences-changed", handler); + }, getUpdateState: () => ipcRenderer.invoke("desktop:get-update-state") as Promise, checkForUpdates: () => diff --git a/packages/desktop/src/product-channel.ts b/packages/desktop/src/product-channel.ts index 6e22f0c4..008b00d2 100644 --- a/packages/desktop/src/product-channel.ts +++ b/packages/desktop/src/product-channel.ts @@ -30,15 +30,18 @@ export interface FactoryProductProvenance { >; } -export interface ProductChannel { - schemaVersion: 1; - channel: "product"; +export interface ProductRelease { version: string; releaseTag: string; - generatedAt: string; minShellVersion: string; requirements: ReleaseCapabilities; runtimes: Record<"win32-x64" | "linux-x64", ProductChannelRuntime>; +} + +export interface ProductChannel extends ProductRelease { + schemaVersion: 1; + channel: "product"; + generatedAt: string; signature: RuntimeSignature; } diff --git a/packages/desktop/src/product-index.test.ts b/packages/desktop/src/product-index.test.ts new file mode 100644 index 00000000..fe285ef0 --- /dev/null +++ b/packages/desktop/src/product-index.test.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + type ProductCompatibilityHost, + type ProductIndexRelease, + parseProductIndex, + resolveProductIndexUrl, + selectHighestCompatibleProductRelease, +} from "./product-index.js"; +import { canonicalSigningPayload } from "./signed-json.js"; + +const channelUrl = + "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-index.json"; + +function release( + version: string, + minShellVersion: string, + engineVersion = "2" +): ProductIndexRelease { + const publishedAt = `2026-08-${version === "0.5.15" ? "25" : "20"}T08:00:00.000Z`; + return { + version, + releaseTag: `v${version}`, + publishedAt, + minShellVersion, + requirements: { + engineVersion, + nodeVersion: "24.19.0", + runtimeHostApiVersion: 1, + apiProtocolVersion: 1, + dataSchemaVersion: 1, + }, + runtimes: { + "win32-x64": { + manifest: "coder-studio-runtime-win32-x64.manifest.json", + manifestSha256: "a".repeat(64), + }, + "linux-x64": { + manifest: "coder-studio-server-runtime-linux-x64.manifest.json", + manifestSha256: "b".repeat(64), + }, + }, + }; +} + +function signedIndex() { + const keys = generateKeyPairSync("ed25519"); + const newest = release("0.5.15", "0.2.0", "3"); + const compatible = release("0.5.14", "0.1.5"); + const unsigned = { + schemaVersion: 1 as const, + channel: "product-index" as const, + generatedAt: "2026-08-25T08:00:00.000Z", + latestVersion: newest.version, + releases: [compatible, newest], + }; + const index = { + ...unsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, canonicalSigningPayload(unsigned), keys.privateKey).toString("base64"), + }, + }; + return { + index, + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + }; +} + +const host: ProductCompatibilityHost = { + shellVersion: "0.1.6", + engineVersion: "2", + nodeVersion: "24.19.0", + runtimeHostApiVersion: 1, + apiProtocolVersion: 1, + dataSchemaVersion: 1, +}; + +describe("Product index", () => { + it("parses a signed index and selects the highest compatible release", () => { + const fixture = signedIndex(); + const parsed = parseProductIndex(fixture.index, fixture.publicKeyPem, channelUrl); + + expect(parsed.latestVersion).toBe("0.5.15"); + expect(selectHighestCompatibleProductRelease(parsed, host)?.version).toBe("0.5.14"); + }); + + it("rejects tampering and an incorrect latestVersion", () => { + const fixture = signedIndex(); + expect(() => + parseProductIndex( + { ...fixture.index, latestVersion: "0.5.14" }, + fixture.publicKeyPem, + channelUrl + ) + ).toThrow("highest release"); + expect(() => + parseProductIndex( + { ...fixture.index, generatedAt: "2026-08-25T09:00:00.000Z" }, + fixture.publicKeyPem, + channelUrl + ) + ).toThrow("signature is invalid"); + expect(() => + parseProductIndex( + { + ...fixture.index, + releases: [{ ...fixture.index.releases[0], version: "latest" }], + latestVersion: "latest", + }, + fixture.publicKeyPem, + channelUrl, + { allowUnsigned: true } + ) + ).toThrow("semantic version"); + }); + + it("returns null when no accepted release is compatible", () => { + const fixture = signedIndex(); + const parsed = parseProductIndex(fixture.index, fixture.publicKeyPem, channelUrl); + expect( + selectHighestCompatibleProductRelease(parsed, { ...host, engineVersion: "9" }) + ).toBeNull(); + }); + + it("derives the signed index URL from the legacy channel URL", () => { + expect( + resolveProductIndexUrl( + "https://github.com/o/r/releases/download/product-stable/product-channel.json?cache=1" + ) + ).toBe("https://github.com/o/r/releases/download/product-stable/product-index.json"); + }); +}); diff --git a/packages/desktop/src/product-index.ts b/packages/desktop/src/product-index.ts new file mode 100644 index 00000000..9c968f57 --- /dev/null +++ b/packages/desktop/src/product-index.ts @@ -0,0 +1,230 @@ +import type { ProductChannel, ProductChannelRuntime, ProductRelease } from "./product-channel.js"; +import { + assertSafeReleaseAssetName, + assertSafeReleaseTag, + parseChannelTimestamp, + parseReleaseCapabilities, + type ReleaseCapabilities, + readChannelSha256, + readChannelString, + resolveVersionedReleaseAsset, +} from "./release-channel.js"; +import { compareVersions, isSemanticVersion, type RuntimeSignature } from "./runtime-manifest.js"; +import { canonicalSigningPayload, verifyEd25519Payload } from "./signed-json.js"; + +export interface ProductCompatibilityHost extends ReleaseCapabilities { + shellVersion: string; +} + +export interface ProductIndex { + schemaVersion: 1; + channel: "product-index"; + generatedAt: string; + latestVersion: string; + releases: ProductIndexRelease[]; + signature: RuntimeSignature; +} + +export type ProductReleaseSource = ProductChannel | ProductIndex; + +export interface ProductIndexRelease { + version: string; + releaseTag: string; + publishedAt: string; + minShellVersion: string; + requirements: ReleaseCapabilities; + runtimes: Record< + "win32-x64" | "linux-x64", + Pick + >; +} + +function parseRuntime( + value: unknown, + label: string, + indexUrl: string, + releaseTag: string +): Pick { + if (!value || typeof value !== "object") { + throw new Error(`Product index ${label} Runtime must be an object`); + } + const candidate = value as Record; + const manifest = readChannelString(candidate.manifest, `Product index ${label}.manifest`); + assertSafeReleaseAssetName(manifest); + resolveVersionedReleaseAsset(indexUrl, releaseTag, manifest); + return { + manifest, + manifestSha256: readChannelSha256( + candidate.manifestSha256, + `Product index ${label}.manifestSha256` + ), + }; +} + +function parseRelease(value: unknown, indexUrl: string, position: number): ProductIndexRelease { + if (!value || typeof value !== "object") { + throw new Error(`Product index release ${position} must be an object`); + } + const candidate = value as Record; + const label = `release ${position}`; + const version = readChannelString(candidate.version, `Product index ${label}.version`); + if (!isSemanticVersion(version)) { + throw new Error(`Product index ${label}.version must be a semantic version`); + } + const releaseTag = readChannelString(candidate.releaseTag, `Product index ${label}.releaseTag`); + assertSafeReleaseTag(releaseTag); + const publishedAt = parseChannelTimestamp( + candidate.publishedAt, + `Product index ${label}.publishedAt` + ); + if (!candidate.runtimes || typeof candidate.runtimes !== "object") { + throw new Error(`Product index ${label}.runtimes must be an object`); + } + const runtimes = candidate.runtimes as Record; + const minShellVersion = readChannelString( + candidate.minShellVersion, + `Product index ${label}.minShellVersion` + ); + if (!isSemanticVersion(minShellVersion)) { + throw new Error(`Product index ${label}.minShellVersion must be a semantic version`); + } + return { + version, + releaseTag, + publishedAt, + minShellVersion, + requirements: parseReleaseCapabilities( + candidate.requirements, + `Product index ${label}.requirements` + ), + runtimes: { + "win32-x64": parseRuntime( + runtimes["win32-x64"], + `${label}.runtimes.win32-x64`, + indexUrl, + releaseTag + ), + "linux-x64": parseRuntime( + runtimes["linux-x64"], + `${label}.runtimes.linux-x64`, + indexUrl, + releaseTag + ), + }, + }; +} + +export function parseProductIndex( + value: unknown, + publicKeyPem: string, + indexUrl: string, + options: { allowUnsigned?: boolean } = {} +): ProductIndex { + if (!value || typeof value !== "object") throw new Error("Product index must be an object"); + const candidate = value as Record; + if (candidate.schemaVersion !== 1) throw new Error("Unsupported Product index schema"); + if (candidate.channel !== "product-index") throw new Error("Product index kind is unsupported"); + if (!Array.isArray(candidate.releases) || candidate.releases.length === 0) { + throw new Error("Product index releases must be a non-empty array"); + } + const releases = candidate.releases.map((release, index) => + parseRelease(release, indexUrl, index) + ); + const versions = new Set(); + const tags = new Set(); + for (const release of releases) { + if (versions.has(release.version)) throw new Error("Product index versions must be unique"); + if (tags.has(release.releaseTag)) throw new Error("Product index release tags must be unique"); + versions.add(release.version); + tags.add(release.releaseTag); + } + const latestVersion = readChannelString(candidate.latestVersion, "Product index latestVersion"); + const highestVersion = [...releases].sort((left, right) => + compareVersions(right.version, left.version) + )[0]?.version; + if (latestVersion !== highestVersion) { + throw new Error("Product index latestVersion must identify the highest release"); + } + const productIndex: ProductIndex = { + schemaVersion: 1, + channel: "product-index", + generatedAt: parseChannelTimestamp(candidate.generatedAt, "Product index generatedAt"), + latestVersion, + releases, + signature: candidate.signature as RuntimeSignature, + }; + if ( + !options.allowUnsigned && + !verifyEd25519Payload( + canonicalSigningPayload(productIndex), + productIndex.signature, + publicKeyPem + ) + ) { + throw new Error("Product index signature is invalid"); + } + return productIndex; +} + +export function resolveProductIndexUrl(productChannelUrl: string): string { + const url = new URL(productChannelUrl); + const separator = url.pathname.lastIndexOf("/"); + url.pathname = `${url.pathname.slice(0, separator + 1)}product-index.json`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function isProductReleaseCompatible( + release: Pick, + host: ProductCompatibilityHost +): boolean { + return ( + isSemanticVersion(host.shellVersion) && + isSemanticVersion(release.minShellVersion) && + compareVersions(host.shellVersion, release.minShellVersion) >= 0 && + release.requirements.engineVersion === host.engineVersion && + release.requirements.nodeVersion === host.nodeVersion && + release.requirements.runtimeHostApiVersion === host.runtimeHostApiVersion && + release.requirements.apiProtocolVersion === host.apiProtocolVersion && + release.requirements.dataSchemaVersion === host.dataSchemaVersion + ); +} + +export function selectHighestCompatibleProductRelease( + source: ProductReleaseSource, + host: ProductCompatibilityHost +): ProductRelease | null { + return listCompatibleProductReleases(source, host)[0] ?? null; +} + +export function listProductReleases(source: ProductReleaseSource): ProductRelease[] { + if (source.channel !== "product-index") return [source]; + return source.releases + .map((selected) => ({ + version: selected.version, + releaseTag: selected.releaseTag, + minShellVersion: selected.minShellVersion, + requirements: selected.requirements, + runtimes: { + "win32-x64": { + ...selected.runtimes["win32-x64"], + version: selected.version, + publishedAt: selected.publishedAt, + }, + "linux-x64": { + ...selected.runtimes["linux-x64"], + version: selected.version, + publishedAt: selected.publishedAt, + }, + }, + })) + .sort((left, right) => compareVersions(right.version, left.version)); +} + +export function listCompatibleProductReleases( + source: ProductReleaseSource, + host: ProductCompatibilityHost +): ProductRelease[] { + return listProductReleases(source).filter((release) => isProductReleaseCompatible(release, host)); +} diff --git a/packages/desktop/src/protocol.ts b/packages/desktop/src/protocol.ts index 68c01960..217d0b8c 100644 --- a/packages/desktop/src/protocol.ts +++ b/packages/desktop/src/protocol.ts @@ -1,3 +1,10 @@ +import type { + DesktopPreferencesPatch, + DesktopPreferencesSnapshot, + DesktopUpdateSettings, + ProductUpdateState, +} from "@coder-studio/core"; + export const DESKTOP_READY_PREFIX = "CODER_STUDIO_DESKTOP_READY "; export const DESKTOP_SHUTDOWN_MESSAGE = "CODER_STUDIO_DESKTOP_SHUTDOWN"; @@ -96,6 +103,7 @@ export interface DesktopRuntimeUpdateState { export interface DesktopApi { platform: NodeJS.Platform; + desktopPreferencesApiVersion: 1; updateApiVersion: 1; getAppVersion(): Promise; selectWorkspaceDirectory(): Promise; @@ -112,6 +120,10 @@ export interface DesktopApi { getActiveEnvironment(): Promise; openEnvironment(environmentId: string): Promise; onEnvironmentProgress(listener: (event: DesktopEnvironmentProgress) => void): () => void; + getDesktopPreferences(): Promise; + initializeDesktopTheme(themeId: string): Promise; + updateDesktopPreferences(patch: DesktopPreferencesPatch): Promise; + onDesktopPreferencesChanged(listener: (snapshot: DesktopPreferencesSnapshot) => void): () => void; getUpdateState(): Promise; checkForUpdates(): Promise; downloadUpdate(): Promise; @@ -129,5 +141,3 @@ export interface DesktopApi { restartForRuntimeUpdate(): Promise; onRuntimeUpdateStateChanged(listener: (state: DesktopRuntimeUpdateState) => void): () => void; } - -import type { DesktopUpdateSettings, ProductUpdateState } from "@coder-studio/core"; diff --git a/packages/desktop/src/runtime-manifest.ts b/packages/desktop/src/runtime-manifest.ts index 7feca004..455d1c1c 100644 --- a/packages/desktop/src/runtime-manifest.ts +++ b/packages/desktop/src/runtime-manifest.ts @@ -218,6 +218,10 @@ function parseSemanticVersion(value: string): ParsedSemanticVersion | null { }; } +export function isSemanticVersion(value: string): boolean { + return parseSemanticVersion(value) !== null; +} + export function compareVersions(left: string, right: string): number { const leftVersion = parseSemanticVersion(left); const rightVersion = parseSemanticVersion(right); diff --git a/packages/server/package.json b/packages/server/package.json index 2bb43c47..5ebcdc1f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,6 +31,7 @@ "dependencies": { "@coder-studio/core": "workspace:*", "@coder-studio/providers": "workspace:*", + "@coder-studio/skill-manager": "workspace:*", "@coder-studio/utils": "workspace:*", "@fastify/compress": "^8.3.1", "@fastify/cors": "^11.2.0", diff --git a/packages/server/src/__tests__/server-runtime-config.test.ts b/packages/server/src/__tests__/server-runtime-config.test.ts index 806cfa0b..764603bd 100644 --- a/packages/server/src/__tests__/server-runtime-config.test.ts +++ b/packages/server/src/__tests__/server-runtime-config.test.ts @@ -90,6 +90,25 @@ describe("server runtime config", () => { expect(readRuntimeConfig()).toBeNull(); }); + it("uses the OS-assigned port in the session host API URL", async () => { + server = await createRuntimeServer({ + stateDir: join(testHomeDir, "server-state-host-api-url"), + host: "127.0.0.1", + port: 0, + writeRuntimeConfig: false, + }); + + const address = server.app.server.address(); + expect(address).not.toBeNull(); + expect(typeof address).toBe("object"); + if (!address || typeof address === "string") { + throw new Error("Expected the test server to expose a TCP listen address"); + } + + expect(address.port).toBeGreaterThan(0); + expect(server.__test__?.hostBridge.getHostApiUrl()).toBe(`http://127.0.0.1:${address.port}`); + }); + it("writes runtime config before post-listen workspace sync completes", async () => { const syncGate = Promise.withResolvers(); const syncStarted = Promise.withResolvers(); diff --git a/packages/server/src/commands/skills/health.ts b/packages/server/src/commands/skills/health.ts index 3097caa6..c497af27 100644 --- a/packages/server/src/commands/skills/health.ts +++ b/packages/server/src/commands/skills/health.ts @@ -1,31 +1,19 @@ import { z } from "zod"; +import { createCoderStudioSkillManager } from "../../skills/host/create-coder-studio-skill-manager.js"; import { registerCommand } from "../../ws/dispatch.js"; -import { listTargets, requireSkillHealthSupport, requireSkillTargetSupport } from "./shared.js"; +import { requireSkillHealthSupport, requireSkillTargetSupport } from "./shared.js"; export function registerSkillHealthCommands(): void { registerCommand("skills.targets.list", z.object({}), async (_args, ctx) => { - return listTargets(ctx); + requireSkillTargetSupport(ctx); + requireSkillHealthSupport(ctx); + return createCoderStudioSkillManager(ctx).listTargets(); }); registerCommand("skills.health.scan", z.object({}), async (_args, ctx) => { requireSkillHealthSupport(ctx); requireSkillTargetSupport(ctx); - const discoveredMounts = await ctx.skillHealthMgr.discoverMounts(ctx.skillMountRepo.list()); - for (const relation of discoveredMounts) { - ctx.skillMountRepo.upsert(relation); - } - - const scannedMounts = await Promise.all( - ctx.skillMountRepo.list().map((relation) => ctx.skillHealthMgr.scanMount(relation)) - ); - for (const relation of scannedMounts) { - ctx.skillMountRepo.upsert(relation); - } - - return { - targets: await listTargets(ctx), - mounts: scannedMounts, - }; + return createCoderStudioSkillManager(ctx).scan(); }); } diff --git a/packages/server/src/commands/skills/install.ts b/packages/server/src/commands/skills/install.ts index 18cdf19c..f590c3b1 100644 --- a/packages/server/src/commands/skills/install.ts +++ b/packages/server/src/commands/skills/install.ts @@ -1,33 +1,15 @@ import { z } from "zod"; +import { createCoderStudioSkillManager } from "../../skills/host/create-coder-studio-skill-manager.js"; import { registerCommand } from "../../ws/dispatch.js"; import { requireSkillInstallSupport } from "./shared.js"; -function canInstallFromSkillHub( - entry: - | { - slug: string; - source: "builtin" | "installed" | "custom"; - origin?: "builtin" | "skillhub" | "filesystem"; - } - | undefined -): boolean { - return !entry || (entry.source === "installed" && entry.origin === "skillhub"); -} - export function registerSkillInstallCommands(): void { registerCommand( "skills.install.start", z.object({ slug: z.string().trim().min(1) }), async (args, ctx) => { requireSkillInstallSupport(ctx); - const existing = ctx.skillLibraryRepo?.get(args.slug); - if (!canInstallFromSkillHub(existing)) { - throw { - code: "skill_slug_conflict", - message: `A skill with slug ${args.slug} already exists`, - }; - } - return ctx.skillInstallMgr.start(args.slug); + return createCoderStudioSkillManager(ctx).startInstall(args.slug); } ); @@ -40,20 +22,7 @@ export function registerSkillInstallCommands(): void { throw { code: "skills_unavailable", message: "Skill management is not configured" }; } - const entry = ctx.skillLibraryRepo.get(args.slug); - if ( - !entry || - entry.source !== "installed" || - entry.origin !== "skillhub" || - entry.installState !== "installed" - ) { - throw { - code: "skill_update_unavailable", - message: `Only installed Skill Hub skills can be updated: ${args.slug}`, - }; - } - - return ctx.skillInstallMgr.start(args.slug); + return createCoderStudioSkillManager(ctx).startUpdate(args.slug); } ); @@ -62,15 +31,7 @@ export function registerSkillInstallCommands(): void { z.object({ jobId: z.string().trim().min(1) }), async (args, ctx) => { requireSkillInstallSupport(ctx); - const job = ctx.skillInstallMgr.get(args.jobId); - if (!job) { - throw { - code: "skill_install_job_not_found", - message: `Install job not found: ${args.jobId}`, - }; - } - - return job; + return createCoderStudioSkillManager(ctx).getInstallJob(args.jobId); } ); } diff --git a/packages/server/src/commands/skills/library.ts b/packages/server/src/commands/skills/library.ts index 4a65339b..4963b8ce 100644 --- a/packages/server/src/commands/skills/library.ts +++ b/packages/server/src/commands/skills/library.ts @@ -1,50 +1,16 @@ import { z } from "zod"; +import { createCoderStudioSkillManager } from "../../skills/host/create-coder-studio-skill-manager.js"; import { registerCommand } from "../../ws/dispatch.js"; -import { - checkSkillHubVersion, - requireSkillsQuerySupport, - requireSkillVersionCheckSupport, -} from "./shared.js"; +import { requireSkillsQuerySupport, requireSkillVersionCheckSupport } from "./shared.js"; export function registerSkillLibraryCommands(): void { registerCommand("skills.library.list", z.object({}), async (_args, ctx) => { requireSkillsQuerySupport(ctx); - - const skillLibraryRepo = ctx.skillLibraryRepo; - const skillMountRepo = ctx.skillMountRepo; - - return skillLibraryRepo.list().map((entry) => { - const mounts = skillMountRepo.listBySkillSlug(entry.slug).filter((item) => item.enabled); - const errors = mounts.filter((item) => item.status === "failed" || item.status === "stale"); - - return { - ...entry, - mountedProviderIds: mounts.map((item) => item.providerId), - mountStatus: - errors.length > 0 - ? "error" - : mounts.length === 0 - ? "unmounted" - : mounts.length === 1 - ? "partially_mounted" - : "fully_mounted", - errorCount: errors.length, - }; - }); + return createCoderStudioSkillManager(ctx).listSkills(); }); registerCommand("skills.versions.check", z.object({}), async (_args, ctx) => { requireSkillVersionCheckSupport(ctx); - - const skillHubEntries = ctx.skillLibraryRepo - .list() - .filter( - (entry) => - entry.source === "installed" && - entry.origin === "skillhub" && - entry.installState === "installed" - ); - - return Promise.all(skillHubEntries.map((entry) => checkSkillHubVersion(entry, ctx))); + return createCoderStudioSkillManager(ctx).checkVersions(); }); } diff --git a/packages/server/src/commands/skills/mount.ts b/packages/server/src/commands/skills/mount.ts index 1b3c3bf4..06632ade 100644 --- a/packages/server/src/commands/skills/mount.ts +++ b/packages/server/src/commands/skills/mount.ts @@ -1,25 +1,13 @@ -import { rm } from "node:fs/promises"; import { z } from "zod"; -import { isPathInsideRoot } from "../../fs/path-safety.js"; -import { readManagedSkillMarker } from "../../skills/managed-skill-metadata.js"; +import { createCoderStudioSkillManager } from "../../skills/host/create-coder-studio-skill-manager.js"; import { registerCommand } from "../../ws/dispatch.js"; import { - broadcastSkillLibraryChanged, requireSkillHealthSupport, requireSkillMountSupport, requireSkillsQuerySupport, requireSkillTargetSupport, } from "./shared.js"; -function canDeleteCustomSkillPath(customRoot: string, libraryPath: string, slug: string): boolean { - if (!customRoot || !libraryPath || !isPathInsideRoot(customRoot, libraryPath)) { - return false; - } - - const marker = readManagedSkillMarker(libraryPath); - return marker?.source === "custom" && marker.slug === slug; -} - export function registerSkillMountCommands(): void { registerCommand( "skills.mount", @@ -33,15 +21,7 @@ export function registerSkillMountCommands(): void { requireSkillHealthSupport(ctx); requireSkillTargetSupport(ctx); - const relation = await ctx.skillMountMgr.mount(args); - const scanned = await ctx.skillHealthMgr.scanMount(relation); - ctx.skillMountRepo.upsert(scanned); - broadcastSkillLibraryChanged(ctx, { - reason: "mounted", - providerId: args.providerId, - skillSlug: args.skillSlug, - }); - return scanned; + return createCoderStudioSkillManager(ctx).sync(args); } ); @@ -53,12 +33,7 @@ export function registerSkillMountCommands(): void { }), async (args, ctx) => { requireSkillMountSupport(ctx); - await ctx.skillMountMgr.unmount(args.providerId, args.skillSlug); - broadcastSkillLibraryChanged(ctx, { - reason: "unmounted", - providerId: args.providerId, - skillSlug: args.skillSlug, - }); + await createCoderStudioSkillManager(ctx).unsync(args.providerId, args.skillSlug); return { ok: true }; } ); @@ -72,60 +47,7 @@ export function registerSkillMountCommands(): void { async (args, ctx) => { requireSkillsQuerySupport(ctx); requireSkillMountSupport(ctx); - - const libraryEntry = ctx.skillLibraryRepo.get(args.slug); - if (libraryEntry?.source === "builtin") { - throw { - code: "skill_uninstall_unavailable", - message: `Built-in skills cannot be uninstalled: ${args.slug}`, - }; - } - - if (libraryEntry?.source === "installed" && libraryEntry.origin === "filesystem") { - throw { - code: "skill_uninstall_unavailable", - message: `Filesystem-installed skills cannot be uninstalled by Coder Studio: ${args.slug}`, - }; - } - - if (libraryEntry?.source === "custom" && !args.force) { - throw { - code: "skill_uninstall_confirmation_required", - message: `Custom skill deletion requires confirmation: ${args.slug}`, - }; - } - - const mounts = ctx.skillMountRepo.listBySkillSlug(args.slug); - const enabledMounts = mounts.filter((entry) => entry.enabled); - if (enabledMounts.length > 0 && !args.force) { - throw { - code: "skill_uninstall_blocked", - message: `Skill still mounted: ${args.slug}`, - details: enabledMounts.map((entry) => entry.providerId), - }; - } - - if (args.force) { - for (const mount of mounts) { - await ctx.skillMountMgr.unmount(mount.providerId, mount.skillSlug).catch(() => undefined); - } - } - - ctx.skillMountRepo.deleteBySkillSlug(args.slug); - ctx.skillLibraryRepo.delete(args.slug); - if (libraryEntry?.source === "custom" && libraryEntry.libraryPath) { - const customRoot = ctx.skillLibraryRepo.getCustomSkillRoot(); - if (canDeleteCustomSkillPath(customRoot, libraryEntry.libraryPath, args.slug)) { - await rm(libraryEntry.libraryPath, { recursive: true, force: true }).catch( - () => undefined - ); - } - } - broadcastSkillLibraryChanged(ctx, { - reason: "uninstalled", - slug: args.slug, - }); - return { deleted: true, slug: args.slug }; + return createCoderStudioSkillManager(ctx).remove(args.slug, args.force); } ); @@ -140,27 +62,7 @@ export function registerSkillMountCommands(): void { requireSkillHealthSupport(ctx); requireSkillTargetSupport(ctx); - const existing = ctx.skillMountRepo.get(args.providerId, args.skillSlug); - if (!existing) { - throw { - code: "skill_mount_not_found", - message: `Mount not found for ${args.providerId}:${args.skillSlug}`, - }; - } - - const relation = await ctx.skillMountMgr.mount({ - providerId: args.providerId, - skillSlug: args.skillSlug, - enabled: existing.enabled, - }); - const scanned = await ctx.skillHealthMgr.scanMount(relation); - ctx.skillMountRepo.upsert(scanned); - broadcastSkillLibraryChanged(ctx, { - reason: "repaired", - providerId: args.providerId, - skillSlug: args.skillSlug, - }); - return scanned; + return createCoderStudioSkillManager(ctx).repair(args.providerId, args.skillSlug); } ); } diff --git a/packages/server/src/commands/skills/query.ts b/packages/server/src/commands/skills/query.ts index 6f9c908f..db3832d3 100644 --- a/packages/server/src/commands/skills/query.ts +++ b/packages/server/src/commands/skills/query.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { createCoderStudioSkillManager } from "../../skills/host/create-coder-studio-skill-manager.js"; import { buildSkillRecommendations } from "../../skills/recommendation.js"; import { inspectWorkspaceIntelligence } from "../../workspace/intelligence.js"; import { registerCommand } from "../../ws/dispatch.js"; @@ -10,24 +11,7 @@ export function registerSkillQueryCommands(): void { z.object({ query: z.string().trim().min(1) }), async (args, ctx) => { requireSkillsQuerySupport(ctx); - - const remote = await ctx.skillsHubClient.search(args.query); - return remote.map((item) => { - const installed = ctx.skillLibraryRepo.get(item.slug); - const mounts = ctx.skillMountRepo - .listBySkillSlug(item.slug) - .filter((entry) => entry.enabled); - - return { - slug: item.slug, - displayName: item.displayName, - description: item.description, - version: item.version, - installed: Boolean(installed), - installedVersion: installed?.version, - mountedProviderIds: mounts.map((entry) => entry.providerId), - }; - }); + return createCoderStudioSkillManager(ctx).searchSkills(args.query); } ); @@ -69,19 +53,7 @@ export function registerSkillQueryCommands(): void { z.object({ slug: z.string().trim().min(1) }), async (args, ctx) => { requireSkillsQuerySupport(ctx); - - const libraryEntry = ctx.skillLibraryRepo.get(args.slug); - const remote = await ctx.skillsHubClient.info(args.slug).catch(() => undefined); - - return { - slug: args.slug, - displayName: remote?.name ?? libraryEntry?.displayName ?? args.slug, - description: remote?.description ?? libraryEntry?.description, - version: remote?.version ?? libraryEntry?.version, - installed: Boolean(libraryEntry), - libraryEntry, - mounts: ctx.skillMountRepo.listBySkillSlug(args.slug), - }; + return createCoderStudioSkillManager(ctx).getSkillInfo(args.slug); } ); } diff --git a/packages/server/src/commands/skills/shared.ts b/packages/server/src/commands/skills/shared.ts index 2d58e04f..cc85f669 100644 --- a/packages/server/src/commands/skills/shared.ts +++ b/packages/server/src/commands/skills/shared.ts @@ -1,5 +1,4 @@ -import { type SkillLibraryEntry, type SkillVersionCheckEntry, Topics } from "@coder-studio/core"; -import { buildAgentSkillTargets } from "../../skills/target-registry.js"; +import { Topics } from "@coder-studio/core"; import type { CommandContext } from "../../ws/dispatch.js"; export function requireSkillsQuerySupport(ctx: CommandContext): asserts ctx is CommandContext & { @@ -105,81 +104,3 @@ export function hasSyncChanges(result: { (result.libraryEntries?.length ?? 0) > 0 ); } - -export async function listTargets(ctx: CommandContext) { - requireSkillTargetSupport(ctx); - requireSkillHealthSupport(ctx); - - const health = await ctx.skillHealthMgr.listTargetHealth(); - return buildAgentSkillTargets({ - providers: ctx.providerRegistry, - resolvedSkillDirByProviderId: Object.fromEntries( - ctx.providerRegistry.map((provider) => [provider.id, provider.skillMountDirectories?.[0]]) - ), - mountCountsByProviderId: ctx.skillMountRepo.countsByProviderId(), - targetHealthByProviderId: health, - }); -} - -function parseVersionParts(version: string): number[] { - return version - .trim() - .replace(/^v(?=\d)/i, "") - .split(".") - .map((segment) => { - const match = segment.match(/^(\d+)/); - return match ? Number.parseInt(match[1]!, 10) : 0; - }); -} - -export function compareVersions(left: string, right: string): number { - const leftParts = parseVersionParts(left); - const rightParts = parseVersionParts(right); - const length = Math.max(leftParts.length, rightParts.length); - - for (let index = 0; index < length; index += 1) { - const leftValue = leftParts[index] ?? 0; - const rightValue = rightParts[index] ?? 0; - if (leftValue > rightValue) { - return 1; - } - if (leftValue < rightValue) { - return -1; - } - } - - return 0; -} - -export async function checkSkillHubVersion( - entry: SkillLibraryEntry, - ctx: CommandContext & { - skillsHubClient: NonNullable; - } -): Promise { - try { - const remote = await ctx.skillsHubClient.info(entry.slug); - const latestVersion = remote.version?.trim(); - if (!latestVersion) { - return { - slug: entry.slug, - currentVersion: entry.version, - status: "unknown", - }; - } - - return { - slug: entry.slug, - currentVersion: entry.version, - latestVersion, - status: compareVersions(latestVersion, entry.version) > 0 ? "update_available" : "up_to_date", - }; - } catch (error) { - return { - slug: entry.slug, - currentVersion: entry.version, - status: "error", - error: error instanceof Error ? error.message : "Version check failed", - }; - } -} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8d896cd7..d42d58bf 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -51,7 +51,7 @@ import { buildProviderRuntimeStatus, type RuntimeStatusDeps, } from "./provider-runtime/runtime-status.js"; -import type { RuntimeHandle } from "./runtime/contract.js"; +import type { RuntimeHandle, RuntimeHostBridge } from "./runtime/contract.js"; import { createNativeRuntime } from "./runtime/native-runtime.js"; import { SessionManager } from "./session/manager.js"; import { SessionAnalysisRunner } from "./session-analysis/runner.js"; @@ -139,6 +139,7 @@ export interface Server { sessionMgr: SessionManager; commandContext: CommandContext; hostContext: HostCommandContext; + hostBridge: RuntimeHostBridge; nativeRuntime: RuntimeHandle; sessionTokenRepo: SessionTokenRepo; }; @@ -347,7 +348,8 @@ export async function createServer( await builtinSkillSyncMgr.sync(); logStartupPhase(null, "builtinSkillSync", startupAt); - const hostBridge = { + let hostApiPort = config.port; + const hostBridge: RuntimeHostBridge = { issueSessionToken: (input: { sessionId: string; workspaceId: string; @@ -358,7 +360,7 @@ export async function createServer( sessionTokenRepo.revokeBySessionId(sessionId); }, getHostApiUrl: () => - `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`, + `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${hostApiPort}`, emitDomainEvent: (event: DomainEvent) => { eventBus.emit(event); }, @@ -797,6 +799,7 @@ export async function createServer( host: config.host, port: config.port, }); + hostApiPort = extractListenPort(app) ?? config.port; logStartupPhase(app, "listen", startupAt); if (configOverrides?.writeRuntimeConfig ?? process.env.NODE_ENV === "production") { @@ -989,6 +992,7 @@ export async function createServer( sessionMgr, commandContext, hostContext, + hostBridge, nativeRuntime, sessionTokenRepo, }, diff --git a/packages/server/src/skills/host/coder-studio-json-storage.ts b/packages/server/src/skills/host/coder-studio-json-storage.ts new file mode 100644 index 00000000..4fcbdd40 --- /dev/null +++ b/packages/server/src/skills/host/coder-studio-json-storage.ts @@ -0,0 +1,19 @@ +import type { SkillJsonDocumentName, SkillJsonStorage } from "@coder-studio/skill-manager"; +import { readJsonFile, writeJsonFileAtomic } from "../../storage/repositories/json-file-store.js"; + +export class CoderStudioSkillJsonStorage implements SkillJsonStorage { + constructor(private readonly filePaths: Partial>) {} + + read(name: SkillJsonDocumentName): unknown | undefined { + const filePath = this.filePaths[name]; + return filePath ? readJsonFile(filePath) : undefined; + } + + write(name: SkillJsonDocumentName, value: unknown): void { + const filePath = this.filePaths[name]; + if (!filePath) { + throw new Error(`No JSON storage path configured for ${name}`); + } + writeJsonFileAtomic(filePath, value); + } +} diff --git a/packages/server/src/skills/host/create-coder-studio-skill-manager.ts b/packages/server/src/skills/host/create-coder-studio-skill-manager.ts new file mode 100644 index 00000000..a39397e4 --- /dev/null +++ b/packages/server/src/skills/host/create-coder-studio-skill-manager.ts @@ -0,0 +1,97 @@ +import { rm } from "node:fs/promises"; +import { Topics } from "@coder-studio/core"; +import { + type SkillLibraryRepositoryPort, + SkillManager, + type SkillMountRepositoryPort, +} from "@coder-studio/skill-manager"; +import { isPathInsideRoot } from "../../fs/path-safety.js"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { readManagedSkillMarker } from "../managed-skill-metadata.js"; +import { buildAgentSkillTargets } from "../target-registry.js"; + +const EMPTY_LIBRARY: SkillLibraryRepositoryPort = { + list: () => [], + get: () => undefined, + set: (entry) => entry, + delete: () => undefined, +}; + +const EMPTY_MOUNTS: SkillMountRepositoryPort = { + list: () => [], + get: () => undefined, + listByProviderId: () => [], + listBySkillSlug: () => [], + upsert: (relation) => relation, + delete: () => undefined, + deleteBySkillSlug: () => undefined, + countsByProviderId: () => ({}), +}; + +export function createCoderStudioSkillManager(ctx: CommandContext): SkillManager { + return new SkillManager({ + library: ctx.skillLibraryRepo ?? EMPTY_LIBRARY, + mounts: ctx.skillMountRepo ?? EMPTY_MOUNTS, + catalog: ctx.skillsHubClient, + installJobs: ctx.skillInstallMgr, + mountHost: ctx.skillMountMgr, + healthHost: ctx.skillHealthMgr, + targetProvider: ctx.skillHealthMgr + ? { + listTargets: async (mountCountsByProviderId) => { + const targetHealthByProviderId = await ctx.skillHealthMgr!.listTargetHealth(); + return buildAgentSkillTargets({ + providers: ctx.providerRegistry, + resolvedSkillDirByProviderId: Object.fromEntries( + ctx.providerRegistry.map((provider) => [ + provider.id, + provider.skillMountDirectories?.[0], + ]) + ), + mountCountsByProviderId, + targetHealthByProviderId, + }); + }, + } + : undefined, + contentHost: ctx.skillLibraryRepo + ? { + canRemove: (entry) => entry.source === "custom", + remove: async (entry) => { + if (entry.source !== "custom") { + return; + } + + const customRoot = ctx.skillLibraryRepo!.getCustomSkillRoot(); + if (!canDeleteCustomSkillPath(customRoot, entry.libraryPath, entry.slug)) { + return; + } + + await rm(entry.libraryPath, { recursive: true, force: true }).catch(() => undefined); + }, + } + : undefined, + events: { + publish: (event) => { + if (typeof ctx.broadcaster.broadcast !== "function") { + return; + } + + ctx.broadcaster.broadcast(Topics.skillLibraryChanged, { + ...event, + changedAt: Date.now(), + }); + }, + }, + hostLabel: "Coder Studio", + }); +} + +function canDeleteCustomSkillPath(customRoot: string, libraryPath: string, slug: string): boolean { + if (!customRoot || !libraryPath || !isPathInsideRoot(customRoot, libraryPath)) { + return false; + } + + const marker = readManagedSkillMarker(libraryPath); + return marker?.source === "custom" && marker.slug === slug; +} diff --git a/packages/server/src/storage/repositories/skill-library-repo.ts b/packages/server/src/storage/repositories/skill-library-repo.ts index 158f1420..f0d81af1 100644 --- a/packages/server/src/storage/repositories/skill-library-repo.ts +++ b/packages/server/src/storage/repositories/skill-library-repo.ts @@ -1,48 +1,31 @@ import { relative } from "node:path"; -import type { SkillLibraryEntry } from "@coder-studio/core"; +import { SkillLibraryRepository } from "@coder-studio/skill-manager"; +import { CoderStudioSkillJsonStorage } from "../../skills/host/coder-studio-json-storage.js"; import { scanDiscoveredSkillEntries } from "../../skills/local-skill-scanner.js"; -import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; -type LegacySkillLibrarySource = SkillLibraryEntry["source"] | "local" | "skillhub"; -type LegacySkillLibraryOrigin = SkillLibraryEntry["origin"] | "local" | "skillhub"; - -type PersistedSkillLibraryEntry = Omit & { - source: LegacySkillLibrarySource; - origin?: LegacySkillLibraryOrigin; -}; - -type ModernPersistedSkillLibraryEntry = Omit & { - source: SkillLibraryEntry["source"]; - origin?: SkillLibraryEntry["origin"]; -}; - -interface SkillLibraryFileRecord { - version: 1; - entries?: Record; +interface SkillLibraryRepoInput { + filePath: string; + builtinRoot?: string; + managedLibraryRoot?: string; + customSkillRoot?: string; + externalSkillRoots?: string[]; + localSkillRoots?: string[]; } -function isSkillLibraryFileRecord(value: unknown): value is SkillLibraryFileRecord { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) && - (value as { version?: unknown }).version === 1 - ); -} - -type SkillLibraryRecord = Record; - -export class SkillLibraryRepo { - constructor( - private readonly input: { - filePath: string; - builtinRoot?: string; - managedLibraryRoot?: string; - customSkillRoot?: string; - externalSkillRoots?: string[]; - localSkillRoots?: string[]; - } - ) {} +export class SkillLibraryRepo extends SkillLibraryRepository { + constructor(private readonly input: SkillLibraryRepoInput) { + super({ + storage: new CoderStudioSkillJsonStorage({ "skills.library": input.filePath }), + discover: () => + scanDiscoveredSkillEntries({ + builtinRoot: input.builtinRoot, + managedLibraryRoot: input.managedLibraryRoot, + customRoot: input.customSkillRoot, + externalRoots: input.externalSkillRoots ?? input.localSkillRoots ?? [], + }), + isCustomLocation: (libraryPath) => isPathInsideRoot(libraryPath, input.customSkillRoot ?? ""), + }); + } getCustomSkillRoot(): string { return this.input.customSkillRoot ?? ""; @@ -55,99 +38,6 @@ export class SkillLibraryRepo { return [...(this.input.localSkillRoots ?? [])]; } - - list(): SkillLibraryEntry[] { - return Object.values(this.load()).sort( - (left, right) => right.updatedAt - left.updatedAt || left.slug.localeCompare(right.slug) - ); - } - - get(slug: string): SkillLibraryEntry | undefined { - return this.load()[slug]; - } - - set(entry: SkillLibraryEntry): SkillLibraryEntry { - const next = this.loadPersisted(); - next[entry.slug] = normalizePersistedEntry(entry, this.getCustomSkillRoot()); - this.save(next); - return next[entry.slug]!; - } - - delete(slug: string): void { - const next = this.loadPersisted(); - delete next[slug]; - this.save(next); - } - - private load(): SkillLibraryRecord { - const persisted = this.loadPersisted(); - const discovered = scanDiscoveredSkillEntries({ - builtinRoot: this.input.builtinRoot, - managedLibraryRoot: this.input.managedLibraryRoot, - customRoot: this.input.customSkillRoot, - externalRoots: this.input.externalSkillRoots ?? this.input.localSkillRoots ?? [], - }); - - for (const entry of discovered) { - const existing = persisted[entry.slug]; - if (!existing || compareEntryPriority(entry, existing) > 0) { - persisted[entry.slug] = entry; - } - } - - return persisted; - } - - private loadPersisted(): SkillLibraryRecord { - const parsed = readJsonFile< - SkillLibraryFileRecord | Record - >(this.input.filePath); - const records = isSkillLibraryFileRecord(parsed) ? (parsed.entries ?? {}) : (parsed ?? {}); - const normalized: SkillLibraryRecord = {}; - - for (const [slug, entry] of Object.entries(records)) { - normalized[slug] = normalizePersistedEntry(entry, this.getCustomSkillRoot()); - } - - return normalized; - } - - private save(entries: Record): void { - writeJsonFileAtomic(this.input.filePath, { version: 1, entries }); - } -} - -function normalizePersistedEntry( - entry: PersistedSkillLibraryEntry, - customRoot: string -): SkillLibraryEntry { - if (entry.source === "skillhub" || entry.origin === "skillhub") { - return { - ...entry, - source: "installed", - origin: "skillhub", - }; - } - - const isUnderCustomRoot = isPathInsideRoot(entry.libraryPath, customRoot); - const isLegacyLocal = entry.source === "local"; - - if (isLegacyLocal || isUnderCustomRoot) { - return { - ...entry, - source: isUnderCustomRoot ? "custom" : "installed", - origin: "filesystem", - }; - } - - const modernEntry = entry as ModernPersistedSkillLibraryEntry; - - return modernEntry.source === "installed" && !modernEntry.origin - ? { - ...modernEntry, - origin: "filesystem", - } - : modernEntry; } function isPathInsideRoot(candidatePath: string, rootPath: string): boolean { @@ -158,20 +48,3 @@ function isPathInsideRoot(candidatePath: string, rootPath: string): boolean { const relativePath = relative(rootPath, candidatePath); return relativePath !== "" && !relativePath.startsWith("..") && !relativePath.includes(":/"); } - -function compareEntryPriority(left: SkillLibraryEntry, right: SkillLibraryEntry): number { - return priorityOf(left) - priorityOf(right); -} - -function priorityOf(entry: SkillLibraryEntry): number { - if (entry.source === "builtin") { - return 4; - } - if (entry.source === "custom") { - return 3; - } - if (entry.source === "installed" && entry.origin === "skillhub") { - return 2; - } - return 1; -} diff --git a/packages/server/src/storage/repositories/skill-mount-repo.ts b/packages/server/src/storage/repositories/skill-mount-repo.ts index b5e69d72..90c7c287 100644 --- a/packages/server/src/storage/repositories/skill-mount-repo.ts +++ b/packages/server/src/storage/repositories/skill-mount-repo.ts @@ -1,78 +1,8 @@ -import type { SkillMountRelation } from "@coder-studio/core"; -import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; +import { SkillMountRepository } from "@coder-studio/skill-manager"; +import { CoderStudioSkillJsonStorage } from "../../skills/host/coder-studio-json-storage.js"; -interface SkillMountFileRecord { - version: 1; - mounts: SkillMountRelation[]; -} - -function sortRelations(relations: SkillMountRelation[]): SkillMountRelation[] { - return [...relations].sort( - (left, right) => - left.providerId.localeCompare(right.providerId) || - left.skillSlug.localeCompare(right.skillSlug) - ); -} - -export class SkillMountRepo { - constructor(private readonly input: { filePath: string }) {} - list(): SkillMountRelation[] { - return sortRelations(this.load()); - } - - get(providerId: string, skillSlug: string): SkillMountRelation | undefined { - return this.list().find( - (entry) => entry.providerId === providerId && entry.skillSlug === skillSlug - ); - } - - listByProviderId(providerId: string): SkillMountRelation[] { - return this.list().filter((entry) => entry.providerId === providerId); - } - - listBySkillSlug(skillSlug: string): SkillMountRelation[] { - return this.list().filter((entry) => entry.skillSlug === skillSlug); - } - - upsert(relation: SkillMountRelation): SkillMountRelation { - const next = this.load().filter( - (entry) => - !(entry.providerId === relation.providerId && entry.skillSlug === relation.skillSlug) - ); - next.push({ ...relation }); - this.save(next); - return relation; - } - - delete(providerId: string, skillSlug: string): void { - this.save( - this.load().filter( - (entry) => !(entry.providerId === providerId && entry.skillSlug === skillSlug) - ) - ); - } - - deleteBySkillSlug(skillSlug: string): void { - this.save(this.load().filter((entry) => entry.skillSlug !== skillSlug)); - } - - countsByProviderId(): Record { - return this.list().reduce>((acc, entry) => { - if (!entry.enabled) return acc; - acc[entry.providerId] = (acc[entry.providerId] ?? 0) + 1; - return acc; - }, {}); - } - - private load(): SkillMountRelation[] { - const parsed = readJsonFile(this.input.filePath); - if (parsed && typeof parsed === "object" && "version" in parsed) { - return [...(parsed.mounts ?? [])]; - } - return parsed ? [...parsed] : []; - } - - private save(mounts: SkillMountRelation[]): void { - writeJsonFileAtomic(this.input.filePath, { version: 1, mounts: sortRelations(mounts) }); +export class SkillMountRepo extends SkillMountRepository { + constructor(input: { filePath: string }) { + super(new CoderStudioSkillJsonStorage({ "skills.mounts": input.filePath })); } } diff --git a/packages/server/src/storage/repositories/skill-target-repo.ts b/packages/server/src/storage/repositories/skill-target-repo.ts index d21bd581..4c521985 100644 --- a/packages/server/src/storage/repositories/skill-target-repo.ts +++ b/packages/server/src/storage/repositories/skill-target-repo.ts @@ -1,62 +1,10 @@ -import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; +import { SkillTargetRepository } from "@coder-studio/skill-manager"; +import { CoderStudioSkillJsonStorage } from "../../skills/host/coder-studio-json-storage.js"; -export interface SkillTargetSettingRecord { - providerId: string; - skillDir?: string; - updatedAt: number; -} - -interface SkillTargetFileRecord { - version: 1; - targets?: Record; -} - -function isSkillTargetFileRecord(value: unknown): value is SkillTargetFileRecord { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) && - (value as { version?: unknown }).version === 1 - ); -} - -type SkillTargetRecord = Record; - -export class SkillTargetRepo { - constructor(private readonly input: { filePath: string }) {} - - list(): SkillTargetSettingRecord[] { - return Object.values(this.load()).sort((left, right) => - left.providerId.localeCompare(right.providerId) - ); - } - - get(providerId: string): SkillTargetSettingRecord | undefined { - return this.load()[providerId]; - } - - set(record: SkillTargetSettingRecord): SkillTargetSettingRecord { - const next = this.load(); - next[record.providerId] = { ...record }; - this.save(next); - return next[record.providerId]!; - } - - delete(providerId: string): void { - const next = this.load(); - delete next[providerId]; - this.save(next); - } - - private load(): SkillTargetRecord { - const parsed = readJsonFile(this.input.filePath); - if (isSkillTargetFileRecord(parsed)) { - return { ...(parsed.targets ?? {}) }; - } - return parsed ? { ...parsed } : {}; - } +export type { SkillTargetSettingRecord } from "@coder-studio/skill-manager"; - private save(targets: Record): void { - writeJsonFileAtomic(this.input.filePath, { version: 1, targets }); +export class SkillTargetRepo extends SkillTargetRepository { + constructor(input: { filePath: string }) { + super(new CoderStudioSkillJsonStorage({ "skills.targets": input.filePath })); } } diff --git a/packages/skill-manager/README.md b/packages/skill-manager/README.md new file mode 100644 index 00000000..4c58bbce --- /dev/null +++ b/packages/skill-manager/README.md @@ -0,0 +1,43 @@ +# `@coder-studio/skill-manager` + +Reusable Skill management domain and application services. + +## Responsibilities + +- Skill library, mount, and target JSON document schemas +- Legacy JSON normalization and repository mutations +- Catalog search and Skill information decoration +- Install and update validation +- Local Skill import orchestration +- Sync, unsync, uninstall, repair, target listing, and health scan orchestration + +## Host responsibilities + +The package does not choose a filesystem path, database, or storage engine. A host supplies +`SkillJsonStorage`, whose `read` and `write` methods can be backed by files, application storage, +or another JSON-capable mechanism. + +The host also supplies the side-effecting adapters it needs: catalog access, download/install +jobs, Skill content import/removal, target discovery, mount execution, health checks, and event +publication. + +For filesystem-origin Skills, the package asks `SkillContentHost.canRemove` before removing JSON +state. This lets a host distinguish an imported, host-managed Skill from a read-only Skill merely +discovered in an external directory. + +Coder Studio's built-in Skill definitions and synchronization, workspace recommendations, and +custom Skill editor are host extensions and are intentionally not implemented by this package. + +## Minimal setup + +```ts +import { + SkillLibraryRepository, + SkillManager, + SkillMountRepository, +} from "@coder-studio/skill-manager"; + +const library = new SkillLibraryRepository({ storage: hostJsonStorage }); +const mounts = new SkillMountRepository(hostJsonStorage); +const skills = new SkillManager({ library, mounts, catalog, installJobs, mountHost, healthHost }); +``` diff --git a/packages/skill-manager/package.json b/packages/skill-manager/package.json new file mode 100644 index 00000000..830168bb --- /dev/null +++ b/packages/skill-manager/package.json @@ -0,0 +1,35 @@ +{ + "name": "@coder-studio/skill-manager", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "engines": { + "node": ">=24.0.0" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/skill-manager/src/domain/skill-management.ts b/packages/skill-manager/src/domain/skill-management.ts new file mode 100644 index 00000000..b25eb886 --- /dev/null +++ b/packages/skill-manager/src/domain/skill-management.ts @@ -0,0 +1,171 @@ +export const SKILL_INSTALL_STATES = ["installed", "installing", "failed"] as const; +export const SKILL_LIBRARY_SOURCES = ["builtin", "installed", "custom"] as const; +export const SKILL_LIBRARY_ORIGINS = ["builtin", "skillhub", "filesystem"] as const; +export const SKILL_LIBRARY_ITEM_STATES = [ + "unmounted", + "partially_mounted", + "fully_mounted", + "error", +] as const; +export const SKILL_MOUNT_STATUSES = [ + "mounted", + "stale", + "missing_target", + "missing_source", + "failed", +] as const; +export const SKILL_VERSION_CHECK_STATUSES = [ + "up_to_date", + "update_available", + "unknown", + "error", +] as const; +export const SKILL_TARGET_HEALTH_STATES = ["healthy", "warning", "error", "unconfigured"] as const; +export const SKILL_MANAGER_ERROR_CODES = [ + "skills_unavailable", + "skill_slug_conflict", + "skill_update_unavailable", + "skill_install_unavailable", + "skill_install_job_not_found", + "skill_import_unavailable", + "skill_mount_unavailable", + "skill_mount_not_found", + "skill_health_unavailable", + "skill_targets_unavailable", + "skill_uninstall_unavailable", + "skill_uninstall_confirmation_required", + "skill_uninstall_blocked", +] as const; + +export type SkillInstallState = (typeof SKILL_INSTALL_STATES)[number]; +export type SkillLibrarySource = (typeof SKILL_LIBRARY_SOURCES)[number]; +export type SkillLibraryOrigin = (typeof SKILL_LIBRARY_ORIGINS)[number]; +export type SkillLibraryItemState = (typeof SKILL_LIBRARY_ITEM_STATES)[number]; +export type SkillMountStatus = (typeof SKILL_MOUNT_STATUSES)[number]; +export type SkillVersionCheckStatus = (typeof SKILL_VERSION_CHECK_STATUSES)[number]; +export type SkillTargetHealthState = (typeof SKILL_TARGET_HEALTH_STATES)[number]; +export type SkillManagerErrorCode = (typeof SKILL_MANAGER_ERROR_CODES)[number]; + +export interface SkillManagerError { + code: SkillManagerErrorCode; + message: string; + details?: string[]; +} + +export function isSkillMountStatus(value: string): value is SkillMountStatus { + return (SKILL_MOUNT_STATUSES as readonly string[]).includes(value); +} + +export interface SkillLibraryEntry { + slug: string; + displayName: string; + description?: string; + version: string; + source: SkillLibrarySource; + origin?: SkillLibraryOrigin; + libraryPath: string; + installState: SkillInstallState; + installedAt: number; + updatedAt: number; + lastError?: string; + builtin?: { + defaultEnabled: boolean; + autoMount: boolean; + }; +} + +export interface SkillLibraryListItem extends SkillLibraryEntry { + mountedProviderIds: string[]; + mountStatus: SkillLibraryItemState; + errorCount: number; +} + +export interface SkillSearchResultItem { + slug: string; + displayName: string; + description?: string; + version?: string; + installed: boolean; + installedVersion?: string; + mountedProviderIds: string[]; +} + +export interface SkillInfoItem { + slug: string; + displayName: string; + description?: string; + version?: string; + installed: boolean; + libraryEntry?: SkillLibraryEntry; + mounts: SkillMountRelation[]; +} + +export interface SkillVersionCheckEntry { + slug: string; + currentVersion: string; + latestVersion?: string; + status: SkillVersionCheckStatus; + error?: string; +} + +export interface AgentSkillTargetEntry { + providerId: string; + displayName: string; + kind: "built_in" | "preset" | "custom"; + skillDir?: string; + mountPreference: "auto"; + lastHealthState: SkillTargetHealthState; + lastHealthError?: string; +} + +export interface SkillMountRelation { + providerId: string; + skillSlug: string; + enabled: boolean; + sourcePath: string; + targetPath: string; + mountModeResolved: "symlink" | "copy"; + status: SkillMountStatus; + lastSyncedAt?: number; + lastError?: string; +} + +export interface SkillInstallStepSnapshot { + id: string; + titleKey: string; + kind: "prepare" | "download" | "extract" | "verify"; + status: "pending" | "running" | "succeeded" | "failed"; + detail?: string; + startedAt?: number; + finishedAt?: number; +} + +export interface SkillInstallFailure { + code: + | "cli_unavailable" + | "search_parse_failed" + | "install_failed" + | "sync_failed" + | "invalid_skill_payload" + | "write_failed" + | "unknown_failure"; + slug: string; + failedStepId: string; + message: string; + detail?: string; +} + +export interface SkillInstallJobSnapshot { + jobId: string; + slug: string; + version?: string; + status: "queued" | "running" | "succeeded" | "failed"; + currentStepId?: string; + steps: SkillInstallStepSnapshot[]; + failure?: SkillInstallFailure; +} + +export interface SkillsHealthScanResult { + targets: Array; + mounts: SkillMountRelation[]; +} diff --git a/packages/skill-manager/src/index.ts b/packages/skill-manager/src/index.ts new file mode 100644 index 00000000..a106a686 --- /dev/null +++ b/packages/skill-manager/src/index.ts @@ -0,0 +1,8 @@ +export * from "./domain/skill-management.js"; +export * from "./ports/hosts.js"; +export * from "./ports/json-storage.js"; +export * from "./ports/repositories.js"; +export * from "./repositories/skill-library-repository.js"; +export * from "./repositories/skill-mount-repository.js"; +export * from "./repositories/skill-target-repository.js"; +export * from "./services/skill-manager.js"; diff --git a/packages/skill-manager/src/ports/hosts.ts b/packages/skill-manager/src/ports/hosts.ts new file mode 100644 index 00000000..06e0c81e --- /dev/null +++ b/packages/skill-manager/src/ports/hosts.ts @@ -0,0 +1,67 @@ +import type { + AgentSkillTargetEntry, + SkillInstallJobSnapshot, + SkillLibraryEntry, + SkillMountRelation, +} from "../domain/skill-management.js"; + +export interface SkillCatalogEntry { + slug: string; + displayName?: string; + name?: string; + description?: string; + version?: string; +} + +export interface SkillCatalogHost { + search(query: string): Promise; + info(slug: string): Promise; +} + +export interface SkillInstallJobsHost { + start(slug: string): Promise; + get(jobId: string): SkillInstallJobSnapshot | undefined; +} + +export interface SkillMountHost { + mount(input: { + providerId: string; + skillSlug: string; + enabled: boolean; + }): Promise; + unmount(providerId: string, skillSlug: string): Promise; +} + +export interface SkillHealthHost { + discoverMounts(existingRelations: SkillMountRelation[]): Promise; + scanMount(relation: SkillMountRelation): Promise; +} + +export interface SkillTargetProvider { + listTargets( + mountCountsByProviderId: Record + ): Promise>; +} + +export interface LocalSkillImportInput { + source: string; + slug: string; + displayName: string; + description?: string; + version?: string; +} + +export interface SkillContentHost { + importLocal?(input: LocalSkillImportInput): Promise<{ libraryPath: string }>; + canRemove?(entry: SkillLibraryEntry): boolean | Promise; + remove?(entry: SkillLibraryEntry): Promise; +} + +export type SkillManagerEvent = + | { reason: "imported"; slug: string } + | { reason: "mounted" | "unmounted" | "repaired"; providerId: string; skillSlug: string } + | { reason: "uninstalled"; slug: string }; + +export interface SkillEventPublisher { + publish(event: SkillManagerEvent): void; +} diff --git a/packages/skill-manager/src/ports/json-storage.ts b/packages/skill-manager/src/ports/json-storage.ts new file mode 100644 index 00000000..06201d22 --- /dev/null +++ b/packages/skill-manager/src/ports/json-storage.ts @@ -0,0 +1,12 @@ +export const SKILL_JSON_DOCUMENTS = ["skills.library", "skills.mounts", "skills.targets"] as const; + +export type SkillJsonDocumentName = (typeof SKILL_JSON_DOCUMENTS)[number]; + +/** + * Physical persistence is supplied by the host. The skill-manager package owns + * document schemas and mutations, but never chooses files, databases, or keys. + */ +export interface SkillJsonStorage { + read(name: SkillJsonDocumentName): unknown | undefined; + write(name: SkillJsonDocumentName, value: unknown): void; +} diff --git a/packages/skill-manager/src/ports/repositories.ts b/packages/skill-manager/src/ports/repositories.ts new file mode 100644 index 00000000..c203c10d --- /dev/null +++ b/packages/skill-manager/src/ports/repositories.ts @@ -0,0 +1,32 @@ +import type { SkillLibraryEntry, SkillMountRelation } from "../domain/skill-management.js"; + +export interface SkillLibraryRepositoryPort { + list(): SkillLibraryEntry[]; + get(slug: string): SkillLibraryEntry | undefined; + set(entry: SkillLibraryEntry): SkillLibraryEntry; + delete(slug: string): void; +} + +export interface SkillMountRepositoryPort { + list(): SkillMountRelation[]; + get(providerId: string, skillSlug: string): SkillMountRelation | undefined; + listByProviderId(providerId: string): SkillMountRelation[]; + listBySkillSlug(skillSlug: string): SkillMountRelation[]; + upsert(relation: SkillMountRelation): SkillMountRelation; + delete(providerId: string, skillSlug: string): void; + deleteBySkillSlug(skillSlug: string): void; + countsByProviderId(): Record; +} + +export interface SkillTargetSettingRecord { + providerId: string; + skillDir?: string; + updatedAt: number; +} + +export interface SkillTargetRepositoryPort { + list(): SkillTargetSettingRecord[]; + get(providerId: string): SkillTargetSettingRecord | undefined; + set(record: SkillTargetSettingRecord): SkillTargetSettingRecord; + delete(providerId: string): void; +} diff --git a/packages/skill-manager/src/repositories/repositories.test.ts b/packages/skill-manager/src/repositories/repositories.test.ts new file mode 100644 index 00000000..536154ff --- /dev/null +++ b/packages/skill-manager/src/repositories/repositories.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { SkillJsonDocumentName, SkillJsonStorage } from "../ports/json-storage.js"; +import { SkillLibraryRepository } from "./skill-library-repository.js"; +import { SkillMountRepository } from "./skill-mount-repository.js"; +import { SkillTargetRepository } from "./skill-target-repository.js"; + +class MemoryJsonStorage implements SkillJsonStorage { + readonly documents = new Map(); + + read(name: SkillJsonDocumentName): unknown | undefined { + return this.documents.get(name); + } + + write(name: SkillJsonDocumentName, value: unknown): void { + this.documents.set(name, value); + } +} + +describe("JSON repositories", () => { + it("normalizes legacy library entries and preserves source priority", () => { + const storage = new MemoryJsonStorage(); + storage.documents.set("skills.library", { + "hub-skill": { + slug: "hub-skill", + displayName: "Hub Skill", + version: "1.0.0", + source: "skillhub", + libraryPath: "/managed/hub-skill", + installState: "installed", + installedAt: 1, + updatedAt: 1, + }, + "custom-skill": { + slug: "custom-skill", + displayName: "Custom Skill", + version: "local", + source: "local", + libraryPath: "/custom/custom-skill", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }, + }); + + const repository = new SkillLibraryRepository({ + storage, + isCustomLocation: (path) => path.startsWith("/custom/"), + discover: () => [ + { + slug: "hub-skill", + displayName: "External Hub Shadow", + version: "local", + source: "installed", + origin: "filesystem", + libraryPath: "/external/hub-skill", + installState: "installed", + installedAt: 1, + updatedAt: 3, + }, + { + slug: "builtin-skill", + displayName: "Builtin Skill", + version: "1", + source: "builtin", + origin: "builtin", + libraryPath: "/builtin/builtin-skill", + installState: "installed", + installedAt: 1, + updatedAt: 4, + }, + ], + }); + + expect(repository.get("hub-skill")).toMatchObject({ + displayName: "Hub Skill", + source: "installed", + origin: "skillhub", + }); + expect(repository.get("custom-skill")).toMatchObject({ + source: "custom", + origin: "filesystem", + }); + expect(repository.get("builtin-skill")?.source).toBe("builtin"); + }); + + it("writes canonical versioned library, mount, and target documents", () => { + const storage = new MemoryJsonStorage(); + const library = new SkillLibraryRepository({ storage }); + const mounts = new SkillMountRepository(storage); + const targets = new SkillTargetRepository(storage); + + library.set({ + slug: "review", + displayName: "Review", + version: "local", + source: "installed", + libraryPath: "/skills/review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + mounts.upsert({ + providerId: "codex", + skillSlug: "review", + enabled: true, + sourcePath: "/skills/review", + targetPath: "/targets/review", + mountModeResolved: "copy", + status: "mounted", + }); + targets.set({ providerId: "codex", skillDir: "/targets", updatedAt: 3 }); + + expect(storage.documents.get("skills.library")).toMatchObject({ + version: 1, + entries: { review: { origin: "filesystem" } }, + }); + expect(storage.documents.get("skills.mounts")).toEqual({ + version: 1, + mounts: [expect.objectContaining({ providerId: "codex", skillSlug: "review" })], + }); + expect(storage.documents.get("skills.targets")).toEqual({ + version: 1, + targets: { + codex: { providerId: "codex", skillDir: "/targets", updatedAt: 3 }, + }, + }); + }); + + it("reads legacy unversioned mount and target documents", () => { + const storage = new MemoryJsonStorage(); + storage.documents.set("skills.mounts", [ + { + providerId: "codex", + skillSlug: "review", + enabled: true, + sourcePath: "/skills/review", + targetPath: "/targets/review", + mountModeResolved: "symlink", + status: "mounted", + }, + ]); + storage.documents.set("skills.targets", { + codex: { providerId: "codex", skillDir: "/targets", updatedAt: 1 }, + }); + + expect(new SkillMountRepository(storage).list()).toHaveLength(1); + expect(new SkillTargetRepository(storage).get("codex")?.skillDir).toBe("/targets"); + }); +}); diff --git a/packages/skill-manager/src/repositories/skill-library-repository.ts b/packages/skill-manager/src/repositories/skill-library-repository.ts new file mode 100644 index 00000000..c5129a77 --- /dev/null +++ b/packages/skill-manager/src/repositories/skill-library-repository.ts @@ -0,0 +1,133 @@ +import type { SkillLibraryEntry } from "../domain/skill-management.js"; +import type { SkillJsonStorage } from "../ports/json-storage.js"; +import type { SkillLibraryRepositoryPort } from "../ports/repositories.js"; + +type LegacySkillLibrarySource = SkillLibraryEntry["source"] | "local" | "skillhub"; +type LegacySkillLibraryOrigin = SkillLibraryEntry["origin"] | "local" | "skillhub"; + +type PersistedSkillLibraryEntry = Omit & { + source: LegacySkillLibrarySource; + origin?: LegacySkillLibraryOrigin; +}; + +type ModernPersistedSkillLibraryEntry = Omit & { + source: SkillLibraryEntry["source"]; + origin?: SkillLibraryEntry["origin"]; +}; + +export interface SkillLibraryJsonDocumentV1 { + version: 1; + entries?: Record; +} + +interface ReadableSkillLibraryJsonDocumentV1 { + version: 1; + entries?: Record; +} + +type SkillLibraryRecord = Record; + +export interface SkillLibraryRepositoryOptions { + storage: SkillJsonStorage; + discover?: () => SkillLibraryEntry[]; + isCustomLocation?: (libraryPath: string) => boolean; +} + +export class SkillLibraryRepository implements SkillLibraryRepositoryPort { + constructor(private readonly options: SkillLibraryRepositoryOptions) {} + + list(): SkillLibraryEntry[] { + return Object.values(this.load()).sort( + (left, right) => right.updatedAt - left.updatedAt || left.slug.localeCompare(right.slug) + ); + } + + get(slug: string): SkillLibraryEntry | undefined { + return this.load()[slug]; + } + + set(entry: SkillLibraryEntry): SkillLibraryEntry { + const next = this.loadPersisted(); + next[entry.slug] = normalizePersistedEntry(entry, this.options.isCustomLocation); + this.save(next); + return next[entry.slug]!; + } + + delete(slug: string): void { + const next = this.loadPersisted(); + delete next[slug]; + this.save(next); + } + + private load(): SkillLibraryRecord { + const persisted = this.loadPersisted(); + for (const entry of this.options.discover?.() ?? []) { + const existing = persisted[entry.slug]; + if (!existing || compareEntryPriority(entry, existing) > 0) { + persisted[entry.slug] = entry; + } + } + return persisted; + } + + private loadPersisted(): SkillLibraryRecord { + const parsed = this.options.storage.read("skills.library") as + | ReadableSkillLibraryJsonDocumentV1 + | Record + | undefined; + const records = isSkillLibraryFileRecord(parsed) ? (parsed.entries ?? {}) : (parsed ?? {}); + const normalized: SkillLibraryRecord = {}; + + for (const [slug, entry] of Object.entries(records)) { + normalized[slug] = normalizePersistedEntry(entry, this.options.isCustomLocation); + } + return normalized; + } + + private save(entries: SkillLibraryRecord): void { + this.options.storage.write("skills.library", { version: 1, entries }); + } +} + +function isSkillLibraryFileRecord(value: unknown): value is ReadableSkillLibraryJsonDocumentV1 { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (value as { version?: unknown }).version === 1 + ); +} + +function normalizePersistedEntry( + entry: PersistedSkillLibraryEntry, + isCustomLocation: ((libraryPath: string) => boolean) | undefined +): SkillLibraryEntry { + if (entry.source === "skillhub" || entry.origin === "skillhub") { + return { ...entry, source: "installed", origin: "skillhub" }; + } + + const isCustom = isCustomLocation?.(entry.libraryPath) === true; + if (entry.source === "local" || isCustom) { + return { + ...entry, + source: isCustom ? "custom" : "installed", + origin: "filesystem", + }; + } + + const modernEntry = entry as ModernPersistedSkillLibraryEntry; + return modernEntry.source === "installed" && !modernEntry.origin + ? { ...modernEntry, origin: "filesystem" } + : modernEntry; +} + +function compareEntryPriority(left: SkillLibraryEntry, right: SkillLibraryEntry): number { + return priorityOf(left) - priorityOf(right); +} + +function priorityOf(entry: SkillLibraryEntry): number { + if (entry.source === "builtin") return 4; + if (entry.source === "custom") return 3; + if (entry.source === "installed" && entry.origin === "skillhub") return 2; + return 1; +} diff --git a/packages/skill-manager/src/repositories/skill-mount-repository.ts b/packages/skill-manager/src/repositories/skill-mount-repository.ts new file mode 100644 index 00000000..58d7b5de --- /dev/null +++ b/packages/skill-manager/src/repositories/skill-mount-repository.ts @@ -0,0 +1,83 @@ +import type { SkillMountRelation } from "../domain/skill-management.js"; +import type { SkillJsonStorage } from "../ports/json-storage.js"; +import type { SkillMountRepositoryPort } from "../ports/repositories.js"; + +export interface SkillMountJsonDocumentV1 { + version: 1; + mounts: SkillMountRelation[]; +} + +export class SkillMountRepository implements SkillMountRepositoryPort { + constructor(private readonly storage: SkillJsonStorage) {} + + list(): SkillMountRelation[] { + return sortRelations(this.load()); + } + + get(providerId: string, skillSlug: string): SkillMountRelation | undefined { + return this.list().find( + (entry) => entry.providerId === providerId && entry.skillSlug === skillSlug + ); + } + + listByProviderId(providerId: string): SkillMountRelation[] { + return this.list().filter((entry) => entry.providerId === providerId); + } + + listBySkillSlug(skillSlug: string): SkillMountRelation[] { + return this.list().filter((entry) => entry.skillSlug === skillSlug); + } + + upsert(relation: SkillMountRelation): SkillMountRelation { + const next = this.load().filter( + (entry) => + !(entry.providerId === relation.providerId && entry.skillSlug === relation.skillSlug) + ); + next.push({ ...relation }); + this.save(next); + return relation; + } + + delete(providerId: string, skillSlug: string): void { + this.save( + this.load().filter( + (entry) => !(entry.providerId === providerId && entry.skillSlug === skillSlug) + ) + ); + } + + deleteBySkillSlug(skillSlug: string): void { + this.save(this.load().filter((entry) => entry.skillSlug !== skillSlug)); + } + + countsByProviderId(): Record { + return this.list().reduce>((acc, entry) => { + if (!entry.enabled) return acc; + acc[entry.providerId] = (acc[entry.providerId] ?? 0) + 1; + return acc; + }, {}); + } + + private load(): SkillMountRelation[] { + const parsed = this.storage.read("skills.mounts") as + | SkillMountJsonDocumentV1 + | SkillMountRelation[] + | undefined; + if (parsed && typeof parsed === "object" && "version" in parsed) { + return [...(parsed.mounts ?? [])]; + } + return parsed ? [...parsed] : []; + } + + private save(mounts: SkillMountRelation[]): void { + this.storage.write("skills.mounts", { version: 1, mounts: sortRelations(mounts) }); + } +} + +function sortRelations(relations: SkillMountRelation[]): SkillMountRelation[] { + return [...relations].sort( + (left, right) => + left.providerId.localeCompare(right.providerId) || + left.skillSlug.localeCompare(right.skillSlug) + ); +} diff --git a/packages/skill-manager/src/repositories/skill-target-repository.ts b/packages/skill-manager/src/repositories/skill-target-repository.ts new file mode 100644 index 00000000..94034dfb --- /dev/null +++ b/packages/skill-manager/src/repositories/skill-target-repository.ts @@ -0,0 +1,60 @@ +import type { SkillJsonStorage } from "../ports/json-storage.js"; +import type { SkillTargetRepositoryPort, SkillTargetSettingRecord } from "../ports/repositories.js"; + +export interface SkillTargetJsonDocumentV1 { + version: 1; + targets?: Record; +} + +type SkillTargetRecord = Record; + +export class SkillTargetRepository implements SkillTargetRepositoryPort { + constructor(private readonly storage: SkillJsonStorage) {} + + list(): SkillTargetSettingRecord[] { + return Object.values(this.load()).sort((left, right) => + left.providerId.localeCompare(right.providerId) + ); + } + + get(providerId: string): SkillTargetSettingRecord | undefined { + return this.load()[providerId]; + } + + set(record: SkillTargetSettingRecord): SkillTargetSettingRecord { + const next = this.load(); + next[record.providerId] = { ...record }; + this.save(next); + return next[record.providerId]!; + } + + delete(providerId: string): void { + const next = this.load(); + delete next[providerId]; + this.save(next); + } + + private load(): SkillTargetRecord { + const parsed = this.storage.read("skills.targets") as + | SkillTargetJsonDocumentV1 + | SkillTargetRecord + | undefined; + if (isSkillTargetFileRecord(parsed)) { + return { ...(parsed.targets ?? {}) }; + } + return parsed ? { ...parsed } : {}; + } + + private save(targets: SkillTargetRecord): void { + this.storage.write("skills.targets", { version: 1, targets }); + } +} + +function isSkillTargetFileRecord(value: unknown): value is SkillTargetJsonDocumentV1 { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (value as { version?: unknown }).version === 1 + ); +} diff --git a/packages/skill-manager/src/services/skill-manager.test.ts b/packages/skill-manager/src/services/skill-manager.test.ts new file mode 100644 index 00000000..16292855 --- /dev/null +++ b/packages/skill-manager/src/services/skill-manager.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SkillJsonDocumentName, SkillJsonStorage } from "../ports/json-storage.js"; +import { SkillLibraryRepository } from "../repositories/skill-library-repository.js"; +import { SkillMountRepository } from "../repositories/skill-mount-repository.js"; +import { SkillManager } from "./skill-manager.js"; + +class MemoryJsonStorage implements SkillJsonStorage { + readonly documents = new Map(); + + read(name: SkillJsonDocumentName): unknown | undefined { + return this.documents.get(name); + } + + write(name: SkillJsonDocumentName, value: unknown): void { + this.documents.set(name, value); + } +} + +function createRepositories() { + const storage = new MemoryJsonStorage(); + return { + library: new SkillLibraryRepository({ storage }), + mounts: new SkillMountRepository(storage), + }; +} + +describe("SkillManager", () => { + it("decorates catalog search results and lists library mount state", async () => { + const { library, mounts } = createRepositories(); + library.set({ + slug: "review", + displayName: "Review", + version: "1.0.0", + source: "installed", + origin: "skillhub", + libraryPath: "/skills/review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + mounts.upsert({ + providerId: "codex", + skillSlug: "review", + enabled: true, + sourcePath: "/skills/review", + targetPath: "/targets/review", + mountModeResolved: "copy", + status: "stale", + }); + const manager = new SkillManager({ + library, + mounts, + catalog: { + search: vi.fn(async () => [ + { slug: "review", displayName: "Code Review", version: "2.0.0" }, + ]), + info: vi.fn(), + }, + }); + + await expect(manager.searchSkills(" review ")).resolves.toEqual([ + expect.objectContaining({ + slug: "review", + installed: true, + installedVersion: "1.0.0", + mountedProviderIds: ["codex"], + }), + ]); + expect(manager.listSkills()).toEqual([ + expect.objectContaining({ mountStatus: "error", errorCount: 1 }), + ]); + }); + + it("validates install and update ownership before starting a host job", async () => { + const { library, mounts } = createRepositories(); + const start = vi.fn(async (slug: string) => ({ + jobId: `job-${slug}`, + slug, + status: "queued" as const, + steps: [], + })); + const manager = new SkillManager({ library, mounts, installJobs: { start, get: vi.fn() } }); + + await expect(manager.startInstall("review")).resolves.toMatchObject({ slug: "review" }); + library.set({ + slug: "local", + displayName: "Local", + version: "local", + source: "installed", + origin: "filesystem", + libraryPath: "/local", + installState: "installed", + installedAt: 1, + updatedAt: 1, + }); + await expect(manager.startInstall("local")).rejects.toMatchObject({ + code: "skill_slug_conflict", + }); + await expect(manager.startUpdate("local")).rejects.toMatchObject({ + code: "skill_update_unavailable", + }); + expect(start).toHaveBeenCalledTimes(1); + }); + + it("checks versions only for installed catalog skills", async () => { + const { library, mounts } = createRepositories(); + library.set({ + slug: "review", + displayName: "Review", + version: "v1.2.0", + source: "installed", + origin: "skillhub", + libraryPath: "/skills/review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + library.set({ + slug: "external", + displayName: "External", + version: "local", + source: "installed", + origin: "filesystem", + libraryPath: "/external", + installState: "installed", + installedAt: 1, + updatedAt: 1, + }); + const info = vi.fn(async (slug: string) => ({ slug, version: "1.3.0" })); + const manager = new SkillManager({ + library, + mounts, + catalog: { search: vi.fn(), info }, + }); + + await expect(manager.checkVersions()).resolves.toEqual([ + { + slug: "review", + currentVersion: "v1.2.0", + latestVersion: "1.3.0", + status: "update_available", + }, + ]); + expect(info).toHaveBeenCalledWith("review"); + }); + + it("imports a local skill through host content operations and persists metadata", async () => { + const { library, mounts } = createRepositories(); + const publish = vi.fn(); + const manager = new SkillManager({ + library, + mounts, + now: () => 42, + contentHost: { + importLocal: vi.fn(async () => ({ libraryPath: "/managed/local-review" })), + }, + events: { publish }, + }); + + await expect( + manager.importLocal({ + source: "/incoming/review", + slug: "local-review", + displayName: "Local Review", + }) + ).resolves.toMatchObject({ + slug: "local-review", + source: "installed", + origin: "filesystem", + libraryPath: "/managed/local-review", + installedAt: 42, + }); + expect(library.get("local-review")).toBeDefined(); + expect(publish).toHaveBeenCalledWith({ reason: "imported", slug: "local-review" }); + }); + + it("lets the host distinguish managed imports from external filesystem skills", async () => { + const { library, mounts } = createRepositories(); + library.set({ + slug: "managed-local", + displayName: "Managed Local", + version: "local", + source: "installed", + origin: "filesystem", + libraryPath: "/managed/local", + installState: "installed", + installedAt: 1, + updatedAt: 1, + }); + const remove = vi.fn(async () => undefined); + const manager = new SkillManager({ + library, + mounts, + mountHost: { mount: vi.fn(), unmount: vi.fn(async () => undefined) }, + contentHost: { canRemove: (entry) => entry.libraryPath.startsWith("/managed/"), remove }, + }); + + await expect(manager.remove("managed-local")).resolves.toMatchObject({ + deleted: true, + slug: "managed-local", + }); + expect(remove).toHaveBeenCalledOnce(); + }); + + it("owns sync, unsync, repair, and relation persistence around host execution", async () => { + const { library, mounts } = createRepositories(); + const relation = { + providerId: "codex", + skillSlug: "review", + enabled: true, + sourcePath: "/skills/review", + targetPath: "/targets/review", + mountModeResolved: "copy" as const, + status: "mounted" as const, + }; + const mount = vi.fn(async () => relation); + const unmount = vi.fn(async () => undefined); + const scanMount = vi.fn(async (input: typeof relation) => ({ + ...input, + status: "mounted" as const, + })); + const manager = new SkillManager({ + library, + mounts, + mountHost: { mount, unmount }, + healthHost: { discoverMounts: vi.fn(async () => []), scanMount }, + }); + + await expect(manager.sync({ providerId: "codex", skillSlug: "review" })).resolves.toMatchObject( + relation + ); + expect(mounts.get("codex", "review")).toBeDefined(); + await expect(manager.repair("codex", "review")).resolves.toMatchObject(relation); + await manager.unsync("codex", "review"); + expect(mounts.get("codex", "review")).toBeUndefined(); + expect(unmount).toHaveBeenCalledWith("codex", "review"); + }); + + it("blocks unsafe removals and delegates managed content deletion to the host", async () => { + const { library, mounts } = createRepositories(); + library.set({ + slug: "custom-review", + displayName: "Custom Review", + version: "local", + source: "custom", + origin: "filesystem", + libraryPath: "/custom/review", + installState: "installed", + installedAt: 1, + updatedAt: 1, + }); + const remove = vi.fn(async () => undefined); + const manager = new SkillManager({ + library, + mounts, + mountHost: { mount: vi.fn(), unmount: vi.fn(async () => undefined) }, + contentHost: { remove }, + }); + + await expect(manager.remove("custom-review")).rejects.toMatchObject({ + code: "skill_uninstall_confirmation_required", + }); + await expect(manager.remove("custom-review", true)).resolves.toEqual({ + deleted: true, + slug: "custom-review", + }); + expect(remove).toHaveBeenCalledWith(expect.objectContaining({ slug: "custom-review" })); + expect(library.get("custom-review")).toBeUndefined(); + }); +}); diff --git a/packages/skill-manager/src/services/skill-manager.ts b/packages/skill-manager/src/services/skill-manager.ts new file mode 100644 index 00000000..cd7bd40c --- /dev/null +++ b/packages/skill-manager/src/services/skill-manager.ts @@ -0,0 +1,398 @@ +import type { + SkillInfoItem, + SkillInstallJobSnapshot, + SkillLibraryEntry, + SkillLibraryListItem, + SkillMountRelation, + SkillSearchResultItem, + SkillsHealthScanResult, + SkillVersionCheckEntry, +} from "../domain/skill-management.js"; +import type { + LocalSkillImportInput, + SkillCatalogHost, + SkillContentHost, + SkillEventPublisher, + SkillHealthHost, + SkillInstallJobsHost, + SkillMountHost, + SkillTargetProvider, +} from "../ports/hosts.js"; +import type { + SkillLibraryRepositoryPort, + SkillMountRepositoryPort, +} from "../ports/repositories.js"; + +export interface SkillManagerDependencies { + library: SkillLibraryRepositoryPort; + mounts: SkillMountRepositoryPort; + catalog?: SkillCatalogHost; + installJobs?: SkillInstallJobsHost; + mountHost?: SkillMountHost; + healthHost?: SkillHealthHost; + targetProvider?: SkillTargetProvider; + contentHost?: SkillContentHost; + events?: SkillEventPublisher; + now?: () => number; + hostLabel?: string; +} + +export class SkillManager { + constructor(private readonly deps: SkillManagerDependencies) {} + + listSkills(): SkillLibraryListItem[] { + return this.deps.library.list().map((entry) => { + const mounts = this.deps.mounts.listBySkillSlug(entry.slug).filter((item) => item.enabled); + const errors = mounts.filter((item) => item.status === "failed" || item.status === "stale"); + + return { + ...entry, + mountedProviderIds: mounts.map((item) => item.providerId), + mountStatus: + errors.length > 0 + ? "error" + : mounts.length === 0 + ? "unmounted" + : mounts.length === 1 + ? "partially_mounted" + : "fully_mounted", + errorCount: errors.length, + }; + }); + } + + async searchSkills(query: string): Promise { + const catalog = this.requireCatalog(); + const remote = await catalog.search(query.trim()); + return remote.map((item) => { + const installed = this.deps.library.get(item.slug); + const mounts = this.deps.mounts.listBySkillSlug(item.slug).filter((entry) => entry.enabled); + + return { + slug: item.slug, + displayName: item.displayName ?? item.name ?? item.slug, + description: item.description, + version: item.version, + installed: Boolean(installed), + installedVersion: installed?.version, + mountedProviderIds: mounts.map((entry) => entry.providerId), + }; + }); + } + + async getSkillInfo(slug: string): Promise { + const catalog = this.requireCatalog(); + const libraryEntry = this.deps.library.get(slug); + const remote = await catalog.info(slug).catch(() => undefined); + + return { + slug, + displayName: remote?.name ?? remote?.displayName ?? libraryEntry?.displayName ?? slug, + description: remote?.description ?? libraryEntry?.description, + version: remote?.version ?? libraryEntry?.version, + installed: Boolean(libraryEntry), + libraryEntry, + mounts: this.deps.mounts.listBySkillSlug(slug), + }; + } + + async checkVersions(): Promise { + const catalog = this.requireCatalog(); + const entries = this.deps.library + .list() + .filter( + (entry) => + entry.source === "installed" && + entry.origin === "skillhub" && + entry.installState === "installed" + ); + + return Promise.all(entries.map((entry) => checkVersion(entry, catalog))); + } + + async startInstall(slug: string): Promise { + const installJobs = this.requireInstallJobs(); + const existing = this.deps.library.get(slug); + if (!canInstallFromSkillHub(existing)) { + throw { + code: "skill_slug_conflict", + message: `A skill with slug ${slug} already exists`, + }; + } + return installJobs.start(slug); + } + + async startUpdate(slug: string): Promise { + const installJobs = this.requireInstallJobs(); + const entry = this.deps.library.get(slug); + if ( + !entry || + entry.source !== "installed" || + entry.origin !== "skillhub" || + entry.installState !== "installed" + ) { + throw { + code: "skill_update_unavailable", + message: `Only installed Skill Hub skills can be updated: ${slug}`, + }; + } + return installJobs.start(slug); + } + + getInstallJob(jobId: string): SkillInstallJobSnapshot { + const job = this.requireInstallJobs().get(jobId); + if (!job) { + throw { + code: "skill_install_job_not_found", + message: `Install job not found: ${jobId}`, + }; + } + return job; + } + + async importLocal(input: LocalSkillImportInput): Promise { + if (this.deps.library.get(input.slug)) { + throw { + code: "skill_slug_conflict", + message: `A skill with slug ${input.slug} already exists`, + }; + } + if (!this.deps.contentHost?.importLocal) { + throw { code: "skill_import_unavailable", message: "Local skill import is not configured" }; + } + + const imported = await this.deps.contentHost.importLocal(input); + const now = this.deps.now?.() ?? Date.now(); + const entry = this.deps.library.set({ + slug: input.slug, + displayName: input.displayName, + description: input.description, + version: input.version ?? "local", + source: "installed", + origin: "filesystem", + libraryPath: imported.libraryPath, + installState: "installed", + installedAt: now, + updatedAt: now, + }); + this.deps.events?.publish({ reason: "imported", slug: entry.slug }); + return entry; + } + + async sync(input: { + providerId: string; + skillSlug: string; + enabled?: boolean; + }): Promise { + const mountHost = this.requireMountHost(); + const healthHost = this.requireHealthHost(); + const relation = await mountHost.mount({ + providerId: input.providerId, + skillSlug: input.skillSlug, + enabled: input.enabled ?? true, + }); + const scanned = await healthHost.scanMount(relation); + this.deps.mounts.upsert(scanned); + this.deps.events?.publish({ + reason: "mounted", + providerId: input.providerId, + skillSlug: input.skillSlug, + }); + return scanned; + } + + async unsync(providerId: string, skillSlug: string): Promise { + await this.requireMountHost().unmount(providerId, skillSlug); + this.deps.mounts.delete(providerId, skillSlug); + this.deps.events?.publish({ reason: "unmounted", providerId, skillSlug }); + } + + async remove(slug: string, force = false): Promise<{ deleted: true; slug: string }> { + const mountHost = this.requireMountHost(); + const libraryEntry = this.deps.library.get(slug); + + if (libraryEntry?.source === "builtin") { + throw { + code: "skill_uninstall_unavailable", + message: `Built-in skills cannot be uninstalled: ${slug}`, + }; + } + if ( + libraryEntry?.source === "installed" && + libraryEntry.origin === "filesystem" && + !(await this.deps.contentHost?.canRemove?.(libraryEntry)) + ) { + throw { + code: "skill_uninstall_unavailable", + message: `Filesystem-installed skills cannot be uninstalled by ${this.deps.hostLabel ?? "the host"}: ${slug}`, + }; + } + if (libraryEntry?.source === "custom" && !force) { + throw { + code: "skill_uninstall_confirmation_required", + message: `Custom skill deletion requires confirmation: ${slug}`, + }; + } + + const mounts = this.deps.mounts.listBySkillSlug(slug); + const enabledMounts = mounts.filter((entry) => entry.enabled); + if (enabledMounts.length > 0 && !force) { + throw { + code: "skill_uninstall_blocked", + message: `Skill still mounted: ${slug}`, + details: enabledMounts.map((entry) => entry.providerId), + }; + } + + if (force) { + for (const mount of mounts) { + await mountHost.unmount(mount.providerId, mount.skillSlug).catch(() => undefined); + } + } + + this.deps.mounts.deleteBySkillSlug(slug); + this.deps.library.delete(slug); + if (libraryEntry) { + await this.deps.contentHost?.remove?.(libraryEntry); + } + this.deps.events?.publish({ reason: "uninstalled", slug }); + return { deleted: true, slug }; + } + + async repair(providerId: string, skillSlug: string): Promise { + const existing = this.deps.mounts.get(providerId, skillSlug); + if (!existing) { + throw { + code: "skill_mount_not_found", + message: `Mount not found for ${providerId}:${skillSlug}`, + }; + } + + const relation = await this.requireMountHost().mount({ + providerId, + skillSlug, + enabled: existing.enabled, + }); + const scanned = await this.requireHealthHost().scanMount(relation); + this.deps.mounts.upsert(scanned); + this.deps.events?.publish({ reason: "repaired", providerId, skillSlug }); + return scanned; + } + + async listTargets() { + return this.requireTargetProvider().listTargets(this.deps.mounts.countsByProviderId()); + } + + async scan(): Promise { + const healthHost = this.requireHealthHost(); + const discovered = await healthHost.discoverMounts(this.deps.mounts.list()); + for (const relation of discovered) { + this.deps.mounts.upsert(relation); + } + + const scanned = await Promise.all( + this.deps.mounts.list().map((relation) => healthHost.scanMount(relation)) + ); + for (const relation of scanned) { + this.deps.mounts.upsert(relation); + } + + return { targets: await this.listTargets(), mounts: scanned }; + } + + private requireCatalog(): SkillCatalogHost { + if (!this.deps.catalog) { + throw { code: "skills_unavailable", message: "Skill catalog is not configured" }; + } + return this.deps.catalog; + } + + private requireInstallJobs(): SkillInstallJobsHost { + if (!this.deps.installJobs) { + throw { + code: "skill_install_unavailable", + message: "Skill install manager is not configured", + }; + } + return this.deps.installJobs; + } + + private requireMountHost(): SkillMountHost { + if (!this.deps.mountHost) { + throw { code: "skill_mount_unavailable", message: "Skill mount manager is not configured" }; + } + return this.deps.mountHost; + } + + private requireHealthHost(): SkillHealthHost { + if (!this.deps.healthHost) { + throw { + code: "skill_health_unavailable", + message: "Skill health manager is not configured", + }; + } + return this.deps.healthHost; + } + + private requireTargetProvider(): SkillTargetProvider { + if (!this.deps.targetProvider) { + throw { code: "skill_targets_unavailable", message: "Skill targets are not configured" }; + } + return this.deps.targetProvider; + } +} + +function canInstallFromSkillHub(entry: SkillLibraryEntry | undefined): boolean { + return !entry || (entry.source === "installed" && entry.origin === "skillhub"); +} + +function parseVersionParts(version: string): number[] { + return version + .trim() + .replace(/^v(?=\d)/i, "") + .split(".") + .map((segment) => { + const match = segment.match(/^(\d+)/); + return match ? Number.parseInt(match[1]!, 10) : 0; + }); +} + +export function compareSkillVersions(left: string, right: string): number { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + if (leftValue > rightValue) return 1; + if (leftValue < rightValue) return -1; + } + return 0; +} + +async function checkVersion( + entry: SkillLibraryEntry, + catalog: SkillCatalogHost +): Promise { + try { + const remote = await catalog.info(entry.slug); + const latestVersion = remote.version?.trim(); + if (!latestVersion) { + return { slug: entry.slug, currentVersion: entry.version, status: "unknown" }; + } + return { + slug: entry.slug, + currentVersion: entry.version, + latestVersion, + status: + compareSkillVersions(latestVersion, entry.version) > 0 ? "update_available" : "up_to_date", + }; + } catch (error) { + return { + slug: entry.slug, + currentVersion: entry.version, + status: "error", + error: error instanceof Error ? error.message : "Version check failed", + }; + } +} diff --git a/packages/skill-manager/tsconfig.json b/packages/skill-manager/tsconfig.json new file mode 100644 index 00000000..c852dfd7 --- /dev/null +++ b/packages/skill-manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/skill-manager/vitest.config.ts b/packages/skill-manager/vitest.config.ts new file mode 100644 index 00000000..3f824fb9 --- /dev/null +++ b/packages/skill-manager/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +}); diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index 15087214..c1e76870 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -1888,6 +1888,139 @@ describe("AppProviders lifecycle recovery", () => { }); }); + it("hydrates and follows Desktop shared theme snapshots without a Server round trip", async () => { + const store = createStore(); + let preferencesListener: + | ((snapshot: import("@coder-studio/core").DesktopPreferencesSnapshot) => void) + | undefined; + const getDesktopPreferences = vi.fn(async () => ({ + schemaVersion: 1 as const, + revision: 3, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "graphite-light" }, + })); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences, + initializeDesktopTheme: vi.fn(), + updateDesktopPreferences: vi.fn(), + onDesktopPreferencesChanged: vi.fn((listener) => { + preferencesListener = listener; + return () => {}; + }), + } as unknown as CoderStudioDesktopApi, + }); + + renderProviders(store); + + await vi.waitFor(() => { + expect(store.get(themeAtom)).toBe("graphite-light"); + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light"); + }); + + act(() => { + preferencesListener?.({ + schemaVersion: 1, + revision: 4, + updatedAt: "2026-08-26T12:31:00.000Z", + appearance: { themeId: "winter-dark" }, + }); + }); + + expect(store.get(themeAtom)).toBe("winter-dark"); + expect(document.documentElement).toHaveAttribute("data-theme", "winter-dark"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("winter-dark")); + }); + + it("ignores an older Desktop read that resolves after a newer change event", async () => { + const store = createStore(); + const initialRead = createDeferred(); + let preferencesListener: + | ((snapshot: import("@coder-studio/core").DesktopPreferencesSnapshot) => void) + | undefined; + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences: vi.fn(() => initialRead.promise), + initializeDesktopTheme: vi.fn(), + updateDesktopPreferences: vi.fn(), + onDesktopPreferencesChanged: vi.fn((listener) => { + preferencesListener = listener; + return () => {}; + }), + } as unknown as CoderStudioDesktopApi, + }); + + renderProviders(store); + act(() => { + preferencesListener?.({ + schemaVersion: 1, + revision: 4, + updatedAt: "2026-08-26T12:31:00.000Z", + appearance: { themeId: "winter-dark" }, + }); + }); + await vi.waitFor(() => expect(store.get(themeAtom)).toBe("winter-dark")); + + await act(async () => { + initialRead.resolve({ + schemaVersion: 1, + revision: 3, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "graphite-light" }, + }); + await initialRead.promise; + }); + + expect(store.get(themeAtom)).toBe("winter-dark"); + expect(document.documentElement).toHaveAttribute("data-theme", "winter-dark"); + }); + + it("initializes an empty Desktop theme from the connected environment once", async () => { + const store = createStore(); + setVisibilityState("visible"); + const emptySnapshot = { + schemaVersion: 1 as const, + revision: 0, + updatedAt: null, + appearance: { themeId: null }, + }; + const initializeDesktopTheme = vi.fn(async (themeId: string) => ({ + schemaVersion: 1 as const, + revision: 1, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId }, + })); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences: vi.fn(async () => emptySnapshot), + initializeDesktopTheme, + updateDesktopPreferences: vi.fn(), + onDesktopPreferencesChanged: vi.fn(() => () => {}), + } as unknown as CoderStudioDesktopApi, + }); + wsState.client!.sendCommand = vi + .fn() + .mockImplementation(async (op: string) => + op === "settings.get" ? { "appearance.themeId": "nord-light" } : undefined + ); + + renderProviders(store); + await vi.waitFor(() => expect(wsState.client?.connect).toHaveBeenCalled()); + act(() => wsState.client?.statusHandler?.("connected")); + + await vi.waitFor(() => expect(initializeDesktopTheme).toHaveBeenCalledWith("nord-light")); + await vi.waitFor(() => { + expect(store.get(themeAtom)).toBe("nord-light"); + expect(document.documentElement).toHaveAttribute("data-theme", "nord-light"); + }); + }); + it("hydrates appearance.personalization from settings.get into the in-memory atom", async () => { const store = createStore(); setVisibilityState("visible"); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index b76790d1..5f7259dc 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -6,6 +6,7 @@ */ import type { + DesktopPreferencesSnapshot, GitBranch, GitStatus, Session, @@ -16,7 +17,7 @@ import type { } from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { Store } from "jotai/vanilla/store"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { applyAppearancePersonalizationToDocument, applyResolvedTheme, @@ -48,6 +49,7 @@ import { import { appearancePersonalizationAtom, authenticatedAtom, themeAtom } from "../atoms/app-ui"; import type { DispatchCommand } from "../atoms/connection"; import { activeWorkspaceIdAtom } from "../atoms/workspaces"; +import { getDesktopPreferencesBridge, readDesktopThemeId } from "../desktop-preferences"; import { normalizePaneLayout, paneLayoutAtomFamily, @@ -377,8 +379,21 @@ export function AppProviders({ children }: AppProvidersProps) { theme: 0, personalization: 0, }); + const desktopPreferencesSnapshotRef = useRef(null); const preferPersistedThemeOnFirstHydrationRef = useRef(false); + const applyDesktopPreferencesSnapshot = useCallback( + (snapshot: DesktopPreferencesSnapshot): DesktopPreferencesSnapshot => { + const current = desktopPreferencesSnapshotRef.current; + if (current && snapshot.revision <= current.revision) return current; + desktopPreferencesSnapshotRef.current = snapshot; + const sharedThemeId = readDesktopThemeId(snapshot); + if (sharedThemeId !== null) setTheme(applyResolvedTheme(sharedThemeId)); + return snapshot; + }, + [setTheme] + ); + // Keep dispatchRef in sync useEffect(() => { dispatchRef.current = dispatch; @@ -578,6 +593,28 @@ export function AppProviders({ children }: AppProvidersProps) { localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedThemeId)); }, [setTheme]); + useEffect(() => { + const bridge = getDesktopPreferencesBridge(); + if (!bridge) return; + let disposed = false; + const applySnapshot = (snapshot: DesktopPreferencesSnapshot) => { + if (disposed) return; + applyDesktopPreferencesSnapshot(snapshot); + }; + const unsubscribe = bridge.onDesktopPreferencesChanged(applySnapshot); + void bridge + .getDesktopPreferences() + .then(applySnapshot) + .catch(() => { + // An older or shutting-down Desktop host can fail here. Server hydration remains the + // compatibility fallback and will initialize the shared value once the bridge recovers. + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, [applyDesktopPreferencesSnapshot]); + useEffect(() => { const resolvedTheme = getThemeById(theme); document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); @@ -646,18 +683,45 @@ export function AppProviders({ children }: AppProvidersProps) { } const settings = result.data; - const shouldHydrateTheme = + const shouldHydrateTheme = () => appearanceSelectionVersionRef.current.theme === appearanceSelectionVersionAtRequestStart.theme; - if (preferPersistedThemeOnFirstHydrationRef.current) { - preferPersistedThemeOnFirstHydrationRef.current = false; - } else if (shouldHydrateTheme) { - const resolvedThemeId = resolveStoredThemeId( - settings["appearance.themeId"] ?? - settings["appearance.theme"] ?? - readStoredThemePreference() - ); - setTheme(resolvedThemeId); + const desktopPreferences = getDesktopPreferencesBridge(); + let hydratedFromDesktop = false; + if (desktopPreferences) { + try { + let snapshot = await desktopPreferences.getDesktopPreferences(); + if (cancelled) return; + snapshot = applyDesktopPreferencesSnapshot(snapshot); + if (readDesktopThemeId(snapshot) === null) { + const migrationThemeId = resolveStoredThemeId( + preferPersistedThemeOnFirstHydrationRef.current + ? readStoredThemePreference() + : (settings["appearance.themeId"] ?? + settings["appearance.theme"] ?? + readStoredThemePreference()) + ); + snapshot = await desktopPreferences.initializeDesktopTheme(migrationThemeId); + if (cancelled) return; + applyDesktopPreferencesSnapshot(snapshot); + } + preferPersistedThemeOnFirstHydrationRef.current = false; + hydratedFromDesktop = true; + } catch { + // Preserve compatibility with an unavailable or older Desktop preference host. + } + } + if (!hydratedFromDesktop) { + if (preferPersistedThemeOnFirstHydrationRef.current) { + preferPersistedThemeOnFirstHydrationRef.current = false; + } else if (shouldHydrateTheme()) { + const resolvedThemeId = resolveStoredThemeId( + settings["appearance.themeId"] ?? + settings["appearance.theme"] ?? + readStoredThemePreference() + ); + setTheme(resolvedThemeId); + } } if ( @@ -673,7 +737,7 @@ export function AppProviders({ children }: AppProvidersProps) { return () => { cancelled = true; }; - }, [connectionStatus, dispatch, setTheme]); + }, [applyDesktopPreferencesSnapshot, connectionStatus, dispatch, setTheme]); useEffect(() => { const unsubscribeTheme = store.sub(themeAtom, () => { diff --git a/packages/web/src/desktop-api.d.ts b/packages/web/src/desktop-api.d.ts index f014d2bc..2c0656cf 100644 --- a/packages/web/src/desktop-api.d.ts +++ b/packages/web/src/desktop-api.d.ts @@ -1,5 +1,7 @@ interface CoderStudioDesktopApi { platform: string; + // Optional while newer Web bundles can still be paired with an older Desktop shell. + desktopPreferencesApiVersion?: 1; updateApiVersion: 1; getAppVersion(): Promise; selectWorkspaceDirectory(): Promise; @@ -23,6 +25,16 @@ interface CoderStudioDesktopApi { getActiveEnvironment(): Promise; openEnvironment(environmentId: string): Promise<{ status: "unchanged" | "opened" }>; onEnvironmentProgress(listener: (event: DesktopEnvironmentProgress) => void): () => void; + getDesktopPreferences?(): Promise; + initializeDesktopTheme?( + themeId: string + ): Promise; + updateDesktopPreferences?( + patch: import("@coder-studio/core").DesktopPreferencesPatch + ): Promise; + onDesktopPreferencesChanged?( + listener: (snapshot: import("@coder-studio/core").DesktopPreferencesSnapshot) => void + ): () => void; getUpdateState(): Promise; checkForUpdates(): Promise; downloadUpdate(): Promise; diff --git a/packages/web/src/desktop-preferences.ts b/packages/web/src/desktop-preferences.ts new file mode 100644 index 00000000..5af7744c --- /dev/null +++ b/packages/web/src/desktop-preferences.ts @@ -0,0 +1,32 @@ +import type { DesktopPreferencesPatch, DesktopPreferencesSnapshot } from "@coder-studio/core"; + +export interface DesktopPreferencesBridge { + getDesktopPreferences(): Promise; + initializeDesktopTheme(themeId: string): Promise; + updateDesktopPreferences(patch: DesktopPreferencesPatch): Promise; + onDesktopPreferencesChanged(listener: (snapshot: DesktopPreferencesSnapshot) => void): () => void; +} + +export function getDesktopPreferencesBridge(): DesktopPreferencesBridge | null { + const bridge = window.coderStudioDesktop; + if ( + bridge?.desktopPreferencesApiVersion !== 1 || + typeof bridge.getDesktopPreferences !== "function" || + typeof bridge.initializeDesktopTheme !== "function" || + typeof bridge.updateDesktopPreferences !== "function" || + typeof bridge.onDesktopPreferencesChanged !== "function" + ) { + return null; + } + return { + getDesktopPreferences: () => bridge.getDesktopPreferences!(), + initializeDesktopTheme: (themeId) => bridge.initializeDesktopTheme!(themeId), + updateDesktopPreferences: (patch) => bridge.updateDesktopPreferences!(patch), + onDesktopPreferencesChanged: (listener) => bridge.onDesktopPreferencesChanged!(listener), + }; +} + +export function readDesktopThemeId(snapshot: DesktopPreferencesSnapshot): string | null { + const themeId = snapshot.appearance?.themeId; + return typeof themeId === "string" && themeId.length > 0 ? themeId : null; +} diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index 4db29fdb..7ae6ae35 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -8,7 +8,7 @@ import { createStore, Provider } from "jotai"; import { BrowserRouter, MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { type ActivationStatus, activationStatusAtom } from "../../../atoms/activation"; -import { appearancePersonalizationAtom } from "../../../atoms/app-ui"; +import { appearancePersonalizationAtom, themeAtom } from "../../../atoms/app-ui"; import { type ConnectionStatus, connectionStatusAtom, @@ -4082,6 +4082,131 @@ describe("SettingsPage", () => { }); }); + it("persists Desktop theme changes through the shared preferences bridge", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + const updateDesktopPreferences = vi.fn(async () => ({ + schemaVersion: 1 as const, + revision: 2, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "graphite-dark" }, + })); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences: vi.fn(), + initializeDesktopTheme: vi.fn(), + updateDesktopPreferences, + onDesktopPreferencesChanged: vi.fn(() => () => {}), + } as unknown as CoderStudioDesktopApi, + }); + const sendCommand = vi + .fn() + .mockImplementation(async (op: string) => + op === "settings.get" ? { "appearance.themeId": "nord-light" } : {} + ); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + fireEvent.click(await screen.findByRole("button", { name: "Theme Mint Dark" })); + fireEvent.click( + within(await screen.findByRole("listbox", { name: "Theme" })).getByRole("option", { + name: "Graphite Dark", + }) + ); + + await waitFor(() => { + expect(updateDesktopPreferences).toHaveBeenCalledWith({ + appearance: { themeId: "graphite-dark" }, + }); + }); + expect( + sendCommand.mock.calls.some( + ([op, args]) => + op === "settings.update" && + (args as { settings?: { appearance?: { themeId?: string } } })?.settings?.appearance + ?.themeId === "graphite-dark" + ) + ).toBe(false); + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark"); + }); + + it("does not let an older Desktop update response replace a newer shared theme", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + const updateDeferred = + createDeferred(); + const updateDesktopPreferences = vi.fn(() => updateDeferred.promise); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences: vi.fn(), + initializeDesktopTheme: vi.fn(), + updateDesktopPreferences, + onDesktopPreferencesChanged: vi.fn(() => () => {}), + } as unknown as CoderStudioDesktopApi, + }); + const store = createConnectedStore( + vi.fn().mockImplementation(async (op: string) => (op === "settings.get" ? {} : {})) + ); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + fireEvent.click(await screen.findByRole("button", { name: "Theme Mint Dark" })); + fireEvent.click( + within(await screen.findByRole("listbox", { name: "Theme" })).getByRole("option", { + name: "Graphite Dark", + }) + ); + await waitFor(() => expect(updateDesktopPreferences).toHaveBeenCalledTimes(1)); + + act(() => store.set(themeAtom, "winter-dark")); + await act(async () => { + updateDeferred.resolve({ + schemaVersion: 1, + revision: 2, + updatedAt: "2026-08-26T12:30:00.000Z", + appearance: { themeId: "graphite-dark" }, + }); + await updateDeferred.promise; + }); + + expect(screen.getByRole("button", { name: "Theme Winter Dark" })).toBeInTheDocument(); + }); + + it("restores the previous theme when the Desktop preference write fails", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + desktopPreferencesApiVersion: 1, + getDesktopPreferences: vi.fn(), + initializeDesktopTheme: vi.fn(), + updateDesktopPreferences: vi.fn(async () => { + throw new Error("shared theme write failed"); + }), + onDesktopPreferencesChanged: vi.fn(() => () => {}), + } as unknown as CoderStudioDesktopApi, + }); + const store = createConnectedStore( + vi.fn().mockImplementation(async (op: string) => (op === "settings.get" ? {} : {})) + ); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + fireEvent.click(await screen.findByRole("button", { name: "Theme Mint Dark" })); + fireEvent.click( + within(await screen.findByRole("listbox", { name: "Theme" })).getByRole("option", { + name: "Graphite Light", + }) + ); + + expect(await screen.findByText("shared theme write failed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Theme Mint Dark" })).toBeInTheDocument(); + expect(document.documentElement).toHaveAttribute("data-theme", "mint-dark"); + }); + it("hydrates the single theme picker from settings.get themeId", async () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); const sendCommand = vi.fn().mockImplementation(async (op: string) => { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 1f5c99b5..32714eba 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -54,6 +54,7 @@ import { appearancePersonalizationAtom, localeAtom, themeAtom } from "../../../a import { connectionStatusAtom, serverInfoAtom } from "../../../atoms/connection"; import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; import { Button, Input, Notice, Pill, Select, Switch, ThemedIcon } from "../../../components/ui"; +import { getDesktopPreferencesBridge } from "../../../desktop-preferences"; import { useViewport } from "../../../hooks/use-viewport"; import { useTranslation } from "../../../lib/i18n"; import { getThemeById, resolveStoredThemeId, THEMES } from "../../../theme"; @@ -791,6 +792,7 @@ export function SettingsPage({ embeddedSection, aboutView = "all" }: SettingsPag Object.prototype.hasOwnProperty.call(settings, "appearance.themeId") || Object.prototype.hasOwnProperty.call(settings, "appearance.theme"); if ( + !getDesktopPreferencesBridge() && hasServerThemeSetting && appearanceSelectionVersionRef.current.theme === appearanceSelectionVersionAtRequestStart.theme @@ -853,9 +855,61 @@ export function SettingsPage({ embeddedSection, aboutView = "all" }: SettingsPag setLocaleState(value); }; - const handleThemeSelection = (value: string) => { + const handleThemeSelection = async (value: string): Promise => { + const nextThemeId = getThemeById(value).id; + const previousThemeId = resolveStoredThemeId(store.get(themeAtom)); + if (nextThemeId === previousThemeId) return true; appearanceSelectionVersionRef.current.theme += 1; - setTheme(value); + const selectionVersion = appearanceSelectionVersionRef.current.theme; + setTheme(nextThemeId); + document.documentElement.setAttribute( + "data-theme", + getThemeById(nextThemeId).documentThemeAttr + ); + + const desktopPreferences = getDesktopPreferencesBridge(); + if (desktopPreferences) { + try { + await desktopPreferences.updateDesktopPreferences({ + appearance: { themeId: nextThemeId }, + }); + return true; + } catch (error) { + if ( + appearanceSelectionVersionRef.current.theme === selectionVersion && + resolveStoredThemeId(store.get(themeAtom)) === nextThemeId + ) { + setTheme(previousThemeId); + document.documentElement.setAttribute( + "data-theme", + getThemeById(previousThemeId).documentThemeAttr + ); + } + setSettingsLoadError( + error instanceof Error ? error.message : settingsLoadFailedUnknownRef.current + ); + return false; + } + } + + const result = await dispatch("settings.update", { + settings: { appearance: { themeId: nextThemeId } }, + }); + if (!result?.ok) { + if ( + appearanceSelectionVersionRef.current.theme === selectionVersion && + resolveStoredThemeId(store.get(themeAtom)) === nextThemeId + ) { + setTheme(previousThemeId); + document.documentElement.setAttribute( + "data-theme", + getThemeById(previousThemeId).documentThemeAttr + ); + } + setSettingsLoadError(result?.error?.message ?? settingsLoadFailedUnknownRef.current); + return false; + } + return true; }; const saveAppearancePersonalization = async (next: AppearancePersonalization) => { @@ -2168,7 +2222,7 @@ interface AppearanceSettingsProps { personalization: AppearancePersonalization; savePersonalization: (value: AppearancePersonalization) => Promise; theme: string; - setTheme: (value: string) => void; + setTheme: (value: string) => Promise; } function AppearanceSettings({ @@ -2192,7 +2246,6 @@ function AppearanceSettings({ const mobileOverrideDescId = useId(); const mobileGlassLabelId = useId(); const mobileGlassDescId = useId(); - const dispatch = useSessionGateDispatch(); const currentThemeId = resolveStoredThemeId(theme); const themeDefinitionsById = new Map( THEMES.map((registeredTheme) => [registeredTheme.id, registeredTheme]) @@ -2261,10 +2314,6 @@ function AppearanceSettings({ const [desktopSurfaceOpacityError, setDesktopSurfaceOpacityError] = useState(null); const [mobileSurfaceOpacityError, setMobileSurfaceOpacityError] = useState(null); const fileInputRef = useRef(null); - const saveSettings = async (settings: Record) => { - return await dispatch("settings.update", { settings }); - }; - useEffect(() => { setBackgroundDimnessDraft(String(personalization.common.backgroundDimness)); setBackgroundBlurDraft(String(personalization.common.backgroundBlur)); @@ -2284,9 +2333,7 @@ function AppearanceSettings({ return; } - setTheme(resolvedTheme.id); - document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); - void saveSettings({ appearance: { themeId: resolvedTheme.id } }); + void setTheme(resolvedTheme.id); }; const updateCommon = ( diff --git a/packages/web/src/features/terminal-panel/links/terminal-link-provider.test.ts b/packages/web/src/features/terminal-panel/links/terminal-link-provider.test.ts index 9a14fd0f..8fd5fdf0 100644 --- a/packages/web/src/features/terminal-panel/links/terminal-link-provider.test.ts +++ b/packages/web/src/features/terminal-panel/links/terminal-link-provider.test.ts @@ -204,6 +204,33 @@ describe("createTerminalWorkspaceLinkProvider", () => { }); }); + it("parses a line suffix followed by Chinese punctuation", async () => { + const { provider, openWorkspaceFile } = createProvider([ + [ + 0, + createBufferLine( + "src/pages/home/components/browser-compatibility-tip/index.tsx:5, 这种格式" + ), + ], + ]); + + const links = await provideLinks(provider, 1); + + expect(links).toHaveLength(1); + expect(links?.[0]?.text).toBe( + "src/pages/home/components/browser-compatibility-tip/index.tsx:5" + ); + + links?.[0]?.activate(new MouseEvent("click"), links[0].text); + expect(openWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: "ws-1", + path: "src/pages/home/components/browser-compatibility-tip/index.tsx", + line: 5, + column: undefined, + source: "manual", + }); + }); + it("does not link truncated workspace paths on their own", async () => { const { provider, openWorkspaceFile } = createProvider( [ diff --git a/packages/web/src/features/terminal-panel/links/terminal-link-provider.ts b/packages/web/src/features/terminal-panel/links/terminal-link-provider.ts index 18eb9673..59aa3e07 100644 --- a/packages/web/src/features/terminal-panel/links/terminal-link-provider.ts +++ b/packages/web/src/features/terminal-panel/links/terminal-link-provider.ts @@ -70,7 +70,7 @@ interface TerminalWorkspaceLinkProviderOptions { const LINK_CANDIDATE_PATTERN = /https?:\/\/[^\s<>"'`]+|[a-z]:[\\/][^\s<>"'`]+|\/[^\s<>"'`]+|(?:[a-z0-9_.@+-]+\/)+[^\s<>"'`]+/giu; -const TRAILING_PUNCTUATION_PATTERN = /[),.;\]}]+$/; +const TRAILING_PUNCTUATION_PATTERN = /[),.;\]},]+$/; const PATH_CONTINUATION_CHARACTER_PATTERN = /[a-z0-9_./@+-]/iu; const TRUNCATED_PATH_TRAILING_PATTERN = /[-./]$/u; const HTTP_URL_PATTERN = /^https?:\/\//iu; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c99402c9..34ba38d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,10 @@ importers: version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/core: + dependencies: + '@coder-studio/skill-manager': + specifier: workspace:* + version: link:../skill-manager devDependencies: '@types/node': specifier: ^25.6.0 @@ -256,6 +260,9 @@ importers: '@coder-studio/providers': specifier: workspace:* version: link:../providers + '@coder-studio/skill-manager': + specifier: workspace:* + version: link:../skill-manager '@coder-studio/utils': specifier: workspace:* version: link:../utils @@ -345,6 +352,15 @@ importers: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/skill-manager: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/utils: devDependencies: '@types/node': diff --git a/scripts/build-product-index.test.ts b/scripts/build-product-index.test.ts new file mode 100644 index 00000000..4274ef02 --- /dev/null +++ b/scripts/build-product-index.test.ts @@ -0,0 +1,205 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ProductChannel } from "../packages/desktop/src/product-channel.js"; +import { parseProductIndex } from "../packages/desktop/src/product-index.js"; +import { canonicalSigningPayload } from "../packages/desktop/src/signed-json.js"; +import { buildProductIndex, parseProductIndexCommand } from "./build-product-index.js"; + +const roots: string[] = []; +const indexUrl = + "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-index.json"; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function signedChannel( + version: string, + privateKey: ReturnType["privateKey"] +): ProductChannel { + const unsigned: Omit = { + schemaVersion: 1, + channel: "product", + version, + releaseTag: `v${version}`, + generatedAt: `2026-08-${version === "0.5.13" ? "25" : "20"}T08:00:00.000Z`, + minShellVersion: "0.1.5", + requirements: { + engineVersion: "2", + nodeVersion: "24.19.0", + runtimeHostApiVersion: 1, + apiProtocolVersion: 1, + dataSchemaVersion: 1, + }, + runtimes: { + "win32-x64": { + version, + publishedAt: "2026-08-20T08:00:00.000Z", + manifest: "coder-studio-runtime-win32-x64.manifest.json", + manifestSha256: (version === "0.5.13" ? "c" : "a").repeat(64), + }, + "linux-x64": { + version, + publishedAt: "2026-08-20T08:00:00.000Z", + manifest: "coder-studio-server-runtime-linux-x64.manifest.json", + manifestSha256: (version === "0.5.13" ? "d" : "b").repeat(64), + }, + }, + }; + return { + ...unsigned, + signature: { + algorithm: "ed25519", + value: sign(null, canonicalSigningPayload(unsigned), privateKey).toString("base64"), + }, + }; +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "coder-studio-index-")); + roots.push(root); + const keys = generateKeyPairSync("ed25519"); + const publicKeyPem = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const candidateFile = join(root, "candidate.json"); + const previousFile = join(root, "previous.json"); + const outputFile = join(root, "product-index.json"); + await Promise.all([ + writeFile(candidateFile, JSON.stringify(signedChannel("0.5.13", keys.privateKey))), + writeFile(previousFile, JSON.stringify(signedChannel("0.5.12", keys.privateKey))), + ]); + return { root, keys, publicKeyPem, privateKeyPem, candidateFile, previousFile, outputFile }; +} + +describe("Product index builder", () => { + it("bootstraps from the previous stable channel and adds the accepted candidate", async () => { + const value = await fixture(); + const index = await buildProductIndex({ + candidateChannelFile: value.candidateFile, + previousChannelFile: value.previousFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }); + + expect(index.latestVersion).toBe("0.5.13"); + expect(index.releases.map((release) => release.version)).toEqual(["0.5.12", "0.5.13"]); + expect( + parseProductIndex( + JSON.parse(await readFile(value.outputFile, "utf8")), + value.publicKeyPem, + indexUrl + ) + ).toEqual(index); + }); + + it("is byte-idempotent when the accepted candidate is already indexed", async () => { + const value = await fixture(); + await buildProductIndex({ + candidateChannelFile: value.candidateFile, + previousChannelFile: value.previousFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }); + const before = await readFile(value.outputFile); + await buildProductIndex({ + candidateChannelFile: value.candidateFile, + existingIndexFile: value.outputFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + generatedAt: "2026-08-25T12:00:00.000Z", + outputFile: value.outputFile, + }); + expect(await readFile(value.outputFile)).toEqual(before); + }); + + it("rejects a conflicting release identity and a tampered existing index", async () => { + const value = await fixture(); + const index = await buildProductIndex({ + candidateChannelFile: value.candidateFile, + previousChannelFile: value.previousFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }); + const conflicting = signedChannel("0.5.13", value.keys.privateKey); + conflicting.runtimes["win32-x64"].manifestSha256 = "e".repeat(64); + const unsignedConflict = Object.fromEntries( + Object.entries(conflicting).filter(([key]) => key !== "signature") + ); + conflicting.signature.value = sign( + null, + canonicalSigningPayload(unsignedConflict), + value.keys.privateKey + ).toString("base64"); + await writeFile(value.candidateFile, JSON.stringify(conflicting)); + await expect( + buildProductIndex({ + candidateChannelFile: value.candidateFile, + existingIndexFile: value.outputFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }) + ).rejects.toThrow("identity conflicts"); + + await writeFile( + value.outputFile, + JSON.stringify({ ...index, generatedAt: "2026-08-25T12:00:00.000Z" }) + ); + await expect( + buildProductIndex({ + candidateChannelFile: value.candidateFile, + existingIndexFile: value.outputFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }) + ).rejects.toThrow("signature is invalid"); + }); + + it("refuses to promote a candidate below the highest accepted Product version", async () => { + const value = await fixture(); + await buildProductIndex({ + candidateChannelFile: value.candidateFile, + previousChannelFile: value.previousFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }); + await writeFile(value.candidateFile, await readFile(value.previousFile)); + + await expect( + buildProductIndex({ + candidateChannelFile: value.candidateFile, + existingIndexFile: value.outputFile, + publicKeyPem: value.publicKeyPem, + privateKeyPem: value.privateKeyPem, + outputFile: value.outputFile, + }) + ).rejects.toThrow("highest indexed version"); + }); + + it("parses the release workflow command", () => { + expect( + parseProductIndexCommand([ + "--", + "--candidate-channel", + "candidate.json", + "--public-key", + "public.pem", + "--private-key", + "private.pem", + "--output", + "product-index.json", + ]) + ).toMatchObject({ + candidateChannelFile: expect.stringContaining("candidate.json"), + outputFile: expect.stringContaining("product-index.json"), + }); + }); +}); diff --git a/scripts/build-product-index.ts b/scripts/build-product-index.ts new file mode 100644 index 00000000..c59a10cf --- /dev/null +++ b/scripts/build-product-index.ts @@ -0,0 +1,237 @@ +import { createPrivateKey, sign } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { writeJsonFileAtomic } from "../packages/desktop/src/atomic-json-file.js"; +import { + type ProductChannel, + parseProductChannel, +} from "../packages/desktop/src/product-channel.js"; +import { + type ProductIndex, + type ProductIndexRelease, + parseProductIndex, +} from "../packages/desktop/src/product-index.js"; +import { compareVersions } from "../packages/desktop/src/runtime-manifest.js"; +import { + type CanonicalJson, + canonicalizeJson, + canonicalSigningPayload, +} from "../packages/desktop/src/signed-json.js"; +import { error, success } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; + +const DEFAULT_PRODUCT_CHANNEL_URL = + "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-channel.json"; +const DEFAULT_PRODUCT_INDEX_URL = + "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-index.json"; + +export interface BuildProductIndexOptions { + candidateChannelFile: string; + existingIndexFile?: string; + previousChannelFile?: string; + publicKeyPem: string; + privateKeyPem: string; + generatedAt?: string; + outputFile: string; + productChannelUrl?: string; + productIndexUrl?: string; +} + +interface ProductIndexCommand { + candidateChannelFile: string; + existingIndexFile?: string; + previousChannelFile?: string; + publicKeyFile: string; + privateKeyFile: string; + generatedAt?: string; + outputFile: string; +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +function toIndexRelease(channel: ProductChannel): ProductIndexRelease { + const windowsPublishedAt = channel.runtimes["win32-x64"].publishedAt; + if (windowsPublishedAt !== channel.runtimes["linux-x64"].publishedAt) { + throw new Error("Product channel Runtime publication timestamps must match"); + } + return { + version: channel.version, + releaseTag: channel.releaseTag, + publishedAt: windowsPublishedAt, + minShellVersion: channel.minShellVersion, + requirements: channel.requirements, + runtimes: { + "win32-x64": { + manifest: channel.runtimes["win32-x64"].manifest, + manifestSha256: channel.runtimes["win32-x64"].manifestSha256, + }, + "linux-x64": { + manifest: channel.runtimes["linux-x64"].manifest, + manifestSha256: channel.runtimes["linux-x64"].manifestSha256, + }, + }, + }; +} + +function releaseIdentity(release: ProductIndexRelease): string { + return canonicalizeJson(release as unknown as CanonicalJson); +} + +function mergeRelease( + releases: ProductIndexRelease[], + candidate: ProductIndexRelease +): { releases: ProductIndexRelease[]; alreadyPresent: boolean } { + const sameVersion = releases.find((release) => release.version === candidate.version); + const sameTag = releases.find((release) => release.releaseTag === candidate.releaseTag); + if (sameVersion || sameTag) { + if ( + sameVersion && + sameTag && + sameVersion === sameTag && + releaseIdentity(sameVersion) === releaseIdentity(candidate) + ) { + return { releases, alreadyPresent: true }; + } + throw new Error( + `Product index release identity conflicts with ${candidate.version} (${candidate.releaseTag})` + ); + } + return { + releases: [...releases, candidate].sort((left, right) => + compareVersions(left.version, right.version) + ), + alreadyPresent: false, + }; +} + +export async function buildProductIndex(options: BuildProductIndexOptions): Promise { + const productChannelUrl = options.productChannelUrl ?? DEFAULT_PRODUCT_CHANNEL_URL; + const productIndexUrl = options.productIndexUrl ?? DEFAULT_PRODUCT_INDEX_URL; + const candidate = parseProductChannel( + await readJson(resolve(options.candidateChannelFile)), + options.publicKeyPem, + productChannelUrl + ); + const candidateRelease = toIndexRelease(candidate); + + let existing: ProductIndex | null = null; + let releases: ProductIndexRelease[] = []; + if (options.existingIndexFile) { + existing = parseProductIndex( + await readJson(resolve(options.existingIndexFile)), + options.publicKeyPem, + productIndexUrl + ); + releases = existing.releases; + } else if (options.previousChannelFile) { + const previous = parseProductChannel( + await readJson(resolve(options.previousChannelFile)), + options.publicKeyPem, + productChannelUrl + ); + releases = [toIndexRelease(previous)]; + } + + const merged = mergeRelease(releases, candidateRelease); + const latestVersion = [...merged.releases].sort((left, right) => + compareVersions(right.version, left.version) + )[0]?.version; + if (!latestVersion) throw new Error("Product index must contain at least one release"); + if (latestVersion !== candidateRelease.version) { + throw new Error("The accepted Product candidate must be the highest indexed version"); + } + if (existing && merged.alreadyPresent) { + await writeJsonFileAtomic(resolve(options.outputFile), existing); + return existing; + } + + const unsigned: Omit = { + schemaVersion: 1, + channel: "product-index", + generatedAt: options.generatedAt ?? candidate.generatedAt, + latestVersion, + releases: merged.releases, + }; + const index: ProductIndex = { + ...unsigned, + signature: { + algorithm: "ed25519", + value: sign( + null, + canonicalSigningPayload(unsigned), + createPrivateKey(options.privateKeyPem) + ).toString("base64"), + }, + }; + const outputFile = resolve(options.outputFile); + await writeJsonFileAtomic(outputFile, index); + return parseProductIndex(await readJson(outputFile), options.publicKeyPem, productIndexUrl); +} + +function readArgumentValue(argv: string[], index: number, option: string): string { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); + return value; +} + +export function parseProductIndexCommand(argvValue: string[]): ProductIndexCommand { + const argv = argvValue[0] === "--" ? argvValue.slice(1) : argvValue; + let candidateChannelFile = ""; + let existingIndexFile: string | undefined; + let previousChannelFile: string | undefined; + let publicKeyFile = ""; + let privateKeyFile = ""; + let generatedAt: string | undefined; + let outputFile = ""; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--candidate-channel") { + candidateChannelFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--existing-index") { + existingIndexFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--previous-channel") { + previousChannelFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--public-key") { + publicKeyFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--private-key") { + privateKeyFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--generated-at") { + generatedAt = readArgumentValue(argv, ++index, argument); + } else if (argument === "--output") { + outputFile = readArgumentValue(argv, ++index, argument); + } else { + throw new Error(`Unknown Product index option: ${argument ?? ""}`); + } + } + if (!candidateChannelFile || !publicKeyFile || !privateKeyFile || !outputFile) { + throw new Error("--candidate-channel, --public-key, --private-key, and --output are required"); + } + return { + candidateChannelFile: resolve(candidateChannelFile), + ...(existingIndexFile ? { existingIndexFile: resolve(existingIndexFile) } : {}), + ...(previousChannelFile ? { previousChannelFile: resolve(previousChannelFile) } : {}), + publicKeyFile: resolve(publicKeyFile), + privateKeyFile: resolve(privateKeyFile), + ...(generatedAt ? { generatedAt } : {}), + outputFile: resolve(outputFile), + }; +} + +async function main(): Promise { + const command = parseProductIndexCommand(process.argv.slice(2)); + const [publicKeyPem, privateKeyPem] = await Promise.all([ + readFile(command.publicKeyFile, "utf8"), + readFile(command.privateKeyFile, "utf8"), + ]); + await buildProductIndex({ ...command, publicKeyPem, privateKeyPem }); + success(`Signed Product index written to ${command.outputFile}`); +} + +if (isDirectExecution(import.meta.url)) { + main().catch((indexError) => { + error(indexError instanceof Error ? indexError.message : String(indexError)); + process.exit(1); + }); +} diff --git a/scripts/desktop-release-artifacts.test.ts b/scripts/desktop-release-artifacts.test.ts index cf177166..979be54d 100644 --- a/scripts/desktop-release-artifacts.test.ts +++ b/scripts/desktop-release-artifacts.test.ts @@ -663,9 +663,12 @@ describe("Desktop release artifacts", () => { ); }); - it("rejects Product Runtime publication assets in a Desktop bundle", async () => { + it.each([ + "product-channel.json", + "product-index.json", + ])("rejects Product publication asset %s in a Desktop bundle", async (productAsset) => { const fixture = await createDesktopFixture(); - await writeFile(join(fixture.root, "coder-studio-runtime-win32-x64.manifest.json"), "{}\n"); + await writeFile(join(fixture.root, productAsset), "{}\n"); await expect(validateDesktopReleaseArtifacts(fixture.options)).rejects.toThrow( /Desktop bundle.*Product Runtime|does not own Product Runtime/i diff --git a/scripts/desktop-release-artifacts.ts b/scripts/desktop-release-artifacts.ts index 2fe23384..fd71f362 100644 --- a/scripts/desktop-release-artifacts.ts +++ b/scripts/desktop-release-artifacts.ts @@ -750,7 +750,8 @@ async function assertDesktopDoesNotOwnProductArtifacts(directory: string): Promi (/^coder-studio-(?:server-)?runtime-.*\.tgz$/.test(path) || path === "coder-studio-runtime-win32-x64.manifest.json" || path === "coder-studio-server-runtime-linux-x64.manifest.json" || - path === "product-channel.json") + path === "product-channel.json" || + path === "product-index.json") ); if (productAsset) { throw new Error( diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index c7a5e408..9cf293da 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -162,7 +162,7 @@ describe("Product publication workflow", () => { "compatibility", ]); expect(jobs.promote.if).toBe("success()"); - expect(jobs.promote.environment).toBeUndefined(); + expect(jobs.promote.environment).toBe("desktop-production"); const publish = jobs["publish-candidate"]; const publishText = jobText(publish); @@ -238,6 +238,9 @@ describe("Product publication workflow", () => { const immutableIndex = steps.findIndex( (candidate) => candidate.name === "Verify accepted immutable Product bytes" ); + const productIndex = steps.findIndex( + (candidate) => candidate.name === "Build next signed Product index" + ); const npmIndex = steps.findIndex( (candidate) => candidate.name === "Advance final npm dist-tag" ); @@ -261,7 +264,8 @@ describe("Product publication workflow", () => { expect(step(promote, "Checkout repository")?.uses).toBe("actions/checkout@v4"); expect(step(promote, "Setup pnpm")?.uses).toBe("pnpm/action-setup@v4"); expect(step(promote, "Install dependencies")?.run).toBe("pnpm install --frozen-lockfile"); - expect(npmIndex).toBeGreaterThan(immutableIndex); + expect(productIndex).toBeGreaterThan(immutableIndex); + expect(npmIndex).toBeGreaterThan(productIndex); expect(releaseIndex).toBeGreaterThan(npmIndex); expect(pointerIndex).toBeGreaterThan(releaseIndex); expect(verifyIndex).toBeGreaterThan(pointerIndex); @@ -272,6 +276,11 @@ describe("Product publication workflow", () => { expect(steps[releaseIndex]?.run).toContain("--prerelease=false --latest=false"); expect(steps[pointerIndex]?.run).toContain("product-stable"); expect(steps[pointerIndex]?.run).toContain("product-channel.json"); + expect(steps[pointerIndex]?.run).toContain("product-index.json"); + expect(steps[productIndex]?.run).toContain("scripts/build-product-index.ts"); + expect(steps[productIndex]?.run).toContain("--existing-index"); + expect(steps[productIndex]?.run).toContain("--previous-channel"); + expect(steps[verifyIndex]?.run).toContain("parseProductIndex"); expect(steps[verifyIndex]?.run).toContain("for attempt in 1 2 3 4 5 6"); expect(steps[verifyIndex]?.run).toContain("sleep 10"); expect(steps[cleanupIndex]?.run).toContain("npm dist-tag rm"); @@ -441,7 +450,16 @@ describe("Desktop publication workflow", () => { const factoryText = jobText(jobs["resolve-factory-product"]); expect(factoryText).toContain("gh release download product-stable"); - expect(factoryText).toContain("prepare-product-release-bundle.ts"); + expect(factoryText).toContain("product-index.json"); + expect(factoryText).toContain("select-product-index-release.ts"); + expect(factoryText).toContain("--previous-to-version"); + expect(factoryText).not.toContain("--before-version"); + expect(factoryText).toContain("is no longer the highest compatible accepted Runtime"); + expect(factoryText).not.toContain("prepare-product-release-bundle.ts"); + expect(factoryText).not.toContain("gh release list"); + expect(readFileSync(workflowPath("desktop-release.yml"), "utf8")).not.toContain( + "build-product-index.ts" + ); expect(factoryText).toContain("product-channel.json"); expect(factoryText).toContain("pnpm release:artifacts validate-product"); expect(factoryText).toContain("factory-product.json"); @@ -450,6 +468,7 @@ describe("Desktop publication workflow", () => { expect(factoryText).toContain("accepted-product-previous-"); expect(factoryText).not.toMatch(/pnpm (?:build:desktop-runtime|build:wsl-runtime)/); expect(jobs["resolve-factory-product"].outputs).toMatchObject({ + current_product_index_sha256: "${{ steps.identity.outputs.current_product_index_sha256 }}", current_product_bundle_artifact: "${{ steps.identity.outputs.current_product_bundle_artifact }}", previous_product_bundle_artifact: diff --git a/scripts/select-product-index-release.test.ts b/scripts/select-product-index-release.test.ts new file mode 100644 index 00000000..53398ab4 --- /dev/null +++ b/scripts/select-product-index-release.test.ts @@ -0,0 +1,113 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ProductIndex, ProductIndexRelease } from "../packages/desktop/src/product-index.js"; +import { canonicalSigningPayload } from "../packages/desktop/src/signed-json.js"; +import { selectProductIndexRelease } from "./select-product-index-release.js"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function release( + version: string, + minShellVersion: string, + engineVersion = "2" +): ProductIndexRelease { + return { + version, + releaseTag: `v${version}`, + publishedAt: "2026-08-25T08:00:00.000Z", + minShellVersion, + requirements: { + engineVersion, + nodeVersion: "24.19.0", + runtimeHostApiVersion: 1, + apiProtocolVersion: 1, + dataSchemaVersion: 1, + }, + runtimes: { + "win32-x64": { + manifest: "coder-studio-runtime-win32-x64.manifest.json", + manifestSha256: "a".repeat(64), + }, + "linux-x64": { + manifest: "coder-studio-server-runtime-linux-x64.manifest.json", + manifestSha256: "b".repeat(64), + }, + }, + }; +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "coder-studio-select-index-")); + roots.push(root); + const keys = generateKeyPairSync("ed25519"); + const releases = [ + release("0.5.11", "0.1.0"), + release("0.5.12", "0.1.0", "3"), + release("0.5.13", "0.1.5"), + ]; + const unsigned: Omit = { + schemaVersion: 1, + channel: "product-index", + generatedAt: "2026-08-25T08:00:00.000Z", + latestVersion: "0.5.13", + releases, + }; + const index = { + ...unsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, canonicalSigningPayload(unsigned), keys.privateKey).toString("base64"), + }, + }; + const buildInfo = { + schemaVersion: 1, + shellVersion: "0.1.5", + builtAt: "2026-08-25T08:00:00.000Z", + publishedAt: "2026-08-25T08:00:00.000Z", + engineVersion: "2", + nodeVersion: "24.19.0", + runtimeHostApiVersion: 1, + apiProtocolVersion: 1, + dataSchemaVersion: 1, + }; + const indexFile = join(root, "product-index.json"); + const buildInfoFile = join(root, "build-info.json"); + const outputFile = join(root, "selected.json"); + await Promise.all([ + writeFile(indexFile, JSON.stringify(index)), + writeFile(buildInfoFile, JSON.stringify(buildInfo)), + ]); + return { + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + indexFile, + buildInfoFile, + outputFile, + }; +} + +describe("Product index release selector", () => { + it("selects the highest compatible accepted Runtime", async () => { + const value = await fixture(); + await expect(selectProductIndexRelease({ ...value })).resolves.toMatchObject({ + version: "0.5.13", + releaseTag: "v0.5.13", + }); + }); + + it("selects the immediately previous accepted Runtime without skipping incompatibility", async () => { + const value = await fixture(); + await expect( + selectProductIndexRelease({ ...value, previousToVersion: "0.5.13" }) + ).resolves.toMatchObject({ version: "0.5.12" }); + await expect( + selectProductIndexRelease({ ...value, requiredVersion: "0.5.12" }) + ).rejects.toThrow("No accepted Product Runtime"); + }); +}); diff --git a/scripts/select-product-index-release.ts b/scripts/select-product-index-release.ts new file mode 100644 index 00000000..348a6946 --- /dev/null +++ b/scripts/select-product-index-release.ts @@ -0,0 +1,149 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { writeJsonFileAtomic } from "../packages/desktop/src/atomic-json-file.js"; +import { parseDesktopBuildInfo } from "../packages/desktop/src/build-info.js"; +import { + listCompatibleProductReleases, + listProductReleases, + type ProductCompatibilityHost, + parseProductIndex, +} from "../packages/desktop/src/product-index.js"; +import { compareVersions } from "../packages/desktop/src/runtime-manifest.js"; +import { error, success } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; + +const DEFAULT_PRODUCT_INDEX_URL = + "https://github.com/spencerkit/coder-studio/releases/download/product-stable/product-index.json"; + +export interface SelectProductIndexReleaseOptions { + indexFile: string; + buildInfoFile: string; + publicKeyPem: string; + outputFile: string; + requiredVersion?: string; + previousToVersion?: string; + productIndexUrl?: string; +} + +interface SelectProductIndexReleaseCommand + extends Omit { + publicKeyFile: string; +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(resolve(path), "utf8")); +} + +function compatibilityHost( + buildInfo: ReturnType +): ProductCompatibilityHost { + if ( + !buildInfo.engineVersion || + !buildInfo.nodeVersion || + !buildInfo.runtimeHostApiVersion || + !buildInfo.apiProtocolVersion || + !buildInfo.dataSchemaVersion + ) { + throw new Error("Desktop build info does not declare complete Runtime capabilities"); + } + return { + shellVersion: buildInfo.shellVersion, + engineVersion: buildInfo.engineVersion, + nodeVersion: buildInfo.nodeVersion, + runtimeHostApiVersion: buildInfo.runtimeHostApiVersion, + apiProtocolVersion: buildInfo.apiProtocolVersion, + dataSchemaVersion: buildInfo.dataSchemaVersion, + }; +} + +export async function selectProductIndexRelease( + options: SelectProductIndexReleaseOptions +): Promise[number]> { + if (options.requiredVersion && options.previousToVersion) { + throw new Error("A required Product version cannot be combined with --previous-to-version"); + } + const index = parseProductIndex( + await readJson(options.indexFile), + options.publicKeyPem, + options.productIndexUrl ?? DEFAULT_PRODUCT_INDEX_URL + ); + const buildInfo = parseDesktopBuildInfo(await readJson(options.buildInfoFile)); + const compatible = listCompatibleProductReleases(index, compatibilityHost(buildInfo)); + const selected = options.requiredVersion + ? compatible.find((release) => release.version === options.requiredVersion) + : options.previousToVersion + ? listProductReleases(index).find( + (release) => compareVersions(release.version, options.previousToVersion as string) < 0 + ) + : compatible[0]; + if (!selected) { + if (options.previousToVersion) { + throw new Error( + `No accepted Product Runtime immediately precedes ${options.previousToVersion}` + ); + } + const qualifier = options.requiredVersion ? ` ${options.requiredVersion}` : ""; + throw new Error( + `No accepted Product Runtime${qualifier} is compatible with Desktop Shell ${buildInfo.shellVersion}` + ); + } + await writeJsonFileAtomic(resolve(options.outputFile), selected); + return selected; +} + +function readArgumentValue(argv: string[], index: number, option: string): string { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); + return value; +} + +export function parseSelectProductIndexReleaseCommand( + argvValue: string[] +): SelectProductIndexReleaseCommand { + const argv = argvValue[0] === "--" ? argvValue.slice(1) : argvValue; + let indexFile = ""; + let buildInfoFile = ""; + let publicKeyFile = ""; + let outputFile = ""; + let requiredVersion: string | undefined; + let previousToVersion: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--index") indexFile = readArgumentValue(argv, ++index, argument); + else if (argument === "--build-info") { + buildInfoFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--public-key") { + publicKeyFile = readArgumentValue(argv, ++index, argument); + } else if (argument === "--output") outputFile = readArgumentValue(argv, ++index, argument); + else if (argument === "--version") { + requiredVersion = readArgumentValue(argv, ++index, argument); + } else if (argument === "--previous-to-version") { + previousToVersion = readArgumentValue(argv, ++index, argument); + } else throw new Error(`Unknown Product index selection option: ${argument ?? ""}`); + } + if (!indexFile || !buildInfoFile || !publicKeyFile || !outputFile) { + throw new Error("--index, --build-info, --public-key, and --output are required"); + } + return { + indexFile: resolve(indexFile), + buildInfoFile: resolve(buildInfoFile), + publicKeyFile: resolve(publicKeyFile), + outputFile: resolve(outputFile), + ...(requiredVersion ? { requiredVersion } : {}), + ...(previousToVersion ? { previousToVersion } : {}), + }; +} + +async function main(): Promise { + const command = parseSelectProductIndexReleaseCommand(process.argv.slice(2)); + const publicKeyPem = await readFile(command.publicKeyFile, "utf8"); + const selected = await selectProductIndexRelease({ ...command, publicKeyPem }); + success(`Selected Product Runtime ${selected.version} (${selected.releaseTag})`); +} + +if (isDirectExecution(import.meta.url)) { + main().catch((selectionError) => { + error(selectionError instanceof Error ? selectionError.message : String(selectionError)); + process.exit(1); + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 9e543bea..8677da17 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "packages/desktop/vitest.config.ts", "packages/providers/vitest.config.ts", "packages/server/vitest.config.ts", + "packages/skill-manager/vitest.config.ts", "packages/utils/vitest.config.ts", "packages/web/vitest.config.ts", ],