diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..bbbe3cd --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,104 @@ +# Publishing to the Visual Studio Marketplace + +Everything in this repository that can be automated is in place: the manifest carries the metadata the Marketplace +requires, `bun run --filter elide package` produces a clean `.vsix`, and `.github/workflows/release.yml` publishes on a +`vX.Y.Z` tag as soon as a `VSCE_PAT` secret exists. What is left are the steps that need a human with an account — +none of them can be done from this repository. + +## What the repo already provides + +| Requirement | Where | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `publisher`, `name`, `version`, `engines.vscode` | `packages/vscode/package.json` (`elide-dev.elide`, `0.1.0`, `^1.105.0`) | +| Icon (256×256 PNG, brand mark) | `packages/vscode/icon.png`, rasterized from Elide's official `elide-square-gradient` artwork; 128×128 is the Marketplace minimum, 256×256 is what it recommends for retina listings | +| Gallery banner | `galleryBanner` (`#0F0F0F`, dark) | +| Marketplace README, changelog, license | `packages/vscode/{README.md,CHANGELOG.md,LICENSE}`, all shipped in the `.vsix` | +| Repository / issues / homepage links | `repository`, `bugs`, `homepage` in the manifest | +| Trust and remote declarations | `capabilities.untrustedWorkspaces` (unsupported), `capabilities.virtualWorkspaces` (unsupported), `extensionKind: ["workspace"]` | +| Reproducible package build | `vscode:prepublish` builds `@elide/ide-core` and the bundle, so `vsce package` works from a clean checkout | +| Automated publication | `Release` workflow: tag check → unit tests → `tools/deploy.sh` → `vsce publish --packagePath` (skipped without `VSCE_PAT`) → GitHub release | + +The `.vsix` contents were verified locally: `extension/{package.json,README.md,CHANGELOG.md,LICENSE.txt,icon.png,dist/extension.js}`, 40 KB total, no source or test files. + +## Remaining manual steps + +### 1. Azure DevOps organization and publisher + +1. Sign in to with the Microsoft/Entra account that should own the extension (a shared Elide + account, not a personal one — the publisher cannot be transferred without support intervention). +2. Create (or reuse) an Azure DevOps organization for Elide. +3. Create the publisher at . The **publisher ID must be + exactly `elide-dev`** — that is what `packages/vscode/package.json` and `tools/deploy.sh` assume. If `elide-dev` is + already taken, the manifest `publisher`, the extension ID used by `plugins.elide.dev` (`elide-dev.elide`), and the + README install links all have to change together. +4. Fill in the publisher display name, logo, and links on the management page. This data is publisher-level and cannot + be set from the manifest. + +### 2. Personal access token + +1. In Azure DevOps: **User settings ▸ Personal access tokens ▸ New Token**. +2. Organization: **All accessible organizations** (required; a single-org token fails with a 401 at publish time). +3. Scopes: **Custom defined ▸ Marketplace ▸ Manage**. Nothing else. +4. Expiration: maximum is 1 year — put a calendar reminder on the rotation date, or the release workflow starts + skipping the publish step silently (it only emits a notice when the secret is missing; an expired token fails loudly + instead). +5. Store it as the repository secret **`VSCE_PAT`** (Settings ▸ Secrets and variables ▸ Actions). No other change is + needed: the workflow step activates on its own. + +### 3. Verify the publisher (optional, recommended) + +Domain verification adds the blue check next to the publisher name. It requires adding a TXT record to a domain you +control (`elide.dev`) from the publisher management page. Not required to publish. + +### 4. First publication + +The very first upload of an extension ID is the risky one — the Marketplace validates the manifest, icon, and README +before the extension appears, and rejected uploads can leave the ID in a half-created state. Two options: + +- **Manual first release (recommended).** From a clean checkout: + ```sh + bun install + bun run --filter elide package # → packages/vscode/elide-0.1.0.vsix + cd packages/vscode + bunx @vscode/vsce login elide-dev # paste the PAT + bunx @vscode/vsce publish --no-dependencies --packagePath elide-0.1.0.vsix + ``` + Then tag `v0.1.0` so the plugins.elide.dev upload and GitHub release happen through the workflow (the workflow's + Marketplace step will re-publish the same version and fail if `VSCE_PAT` is already set — publish manually *or* by + tag for the first release, not both). +- **Tag-driven.** Set `VSCE_PAT` first, then push `v0.1.0` and watch the `Release` run. + +Marketplace validation runs asynchronously after upload; the listing goes live within a few minutes, and a validation +failure arrives by email to the publisher account. + +### 5. Post-publication follow-ups + +- Add the Marketplace badges to the READMEs once the listing exists, e.g. + `https://img.shields.io/visual-studio-marketplace/v/elide-dev.elide` (version) and `.../i/elide-dev.elide` + (installs). `img.shields.io` is on vsce's trusted-badge list, so these are safe in the packaged README. +- Replace the "Until the extension is on the Marketplace…" section of the root `README.md` with the Marketplace + install instructions (`code --install-extension elide-dev.elide`), keeping the `plugins.elide.dev` link as the + fallback for air-gapped installs. +- Drop `"preview": true` from `packages/vscode/package.json` when the extension leaves 0.x, so the "Preview" badge + disappears from the listing. +- Consider publishing to **Open VSX** () as well, for VSCodium, Cursor, Windsurf, and Gitpod + users: create an Eclipse Foundation account, sign the publisher agreement, mint an access token, and add an + `npx ovsx publish` step next to the Marketplace step. This needs its own account and agreement, so it is a separate + manual decision. +- Enable **private vulnerability reporting** in the repository settings (Settings ▸ Code security) so the + `security/advisories/new` link in `SECURITY.md` resolves; it 404s for reporters until that switch is on. +- Confirm the org identifiers these files assert, none of which can be checked from this repository: + - `engineering@elide.dev` is the fallback contact in `SECURITY.md` and the enforcement contact in + `CODE_OF_CONDUCT.md`. It is the address on Elide's release GPG key, so it exists — but confirm it routes to + people who should receive vulnerability and conduct reports, or swap in a dedicated mailbox. + - `.github/CODEOWNERS` lists `@sgammon @darvld`, mirroring the main Elide repository's ownership. Switch to a + GitHub team if one is created; CODEOWNERS silently fails for handles without write access to the repo. + +## Things that intentionally were not changed + +- `packages/vscode/package.json` keeps `"private": true`. `vsce` ignores the field (it is checked by npm, not by the + Marketplace); it prevents an accidental `npm publish` of the extension package. +- The extension is not marked as a pre-release (`--pre-release`); `preview: true` is metadata only and does not affect + the release channel. +- No platform-specific (`--target`) packages: the extension is pure JavaScript and depends on a locally installed + Elide CLI. diff --git a/README.md b/README.md index 39ae134..6b59470 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,10 @@ Opening a folder that contains `elide.pkl` runs a **sync**: `intellij.jdkForSymbolResolution` is written to **user** settings. A running Kotlin LSP is then asked to reload. `workspace.json` is a generated artifact — add it to `.gitignore`. Every `elide.pkl` under a workspace folder (outside -`.dev/` and `node_modules/`) becomes a set of modules in that folder's single `workspace.json`. +`.dev/` and `node_modules/`) becomes a set of modules in that folder's single `workspace.json`. A manifest nested +inside another project's directory — a vendored checkout, a sample, a fixture — is a separate build the enclosing +project does not invoke: only the outermost manifest of each tree is imported, and changes to the nested ones do not +trigger a sync. ### Mixed-editor checkouts (`.idea`, Gradle, Maven) diff --git a/bun.lock b/bun.lock index 84ae7a9..10f4a4c 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ "@types/vscode": "^1.105.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.6.0", - "esbuild": "^0.25.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0", }, }, @@ -65,57 +65,57 @@ "@elide/ide-core": ["@elide/ide-core@workspace:packages/core"], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -321,7 +321,7 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], diff --git a/packages/core/src/elide.ts b/packages/core/src/elide.ts index d0fc496..0040647 100644 --- a/packages/core/src/elide.ts +++ b/packages/core/src/elide.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { ElideCommandFailedError, ElideNotFoundError, InvalidElideHomeError, ManifestParseError } from "./errors.js"; import { decodeManifest, type Manifest } from "./manifest.js"; +import { isPathUnder, normalizePath } from "./sourceRoots.js"; export interface ElideDistribution { /** Distribution root, e.g. `~/.local/share/elide`. */ @@ -292,6 +293,34 @@ export function isLockfileName(fileName: string): boolean { return fileName.startsWith(LOCKFILE_PREFIX) && fileName.endsWith(LOCKFILE_EXTENSION); } +/** Whether `p` is a strict descendant of directory `dir`; both are resolved and compared as normalized paths. */ +export function isNestedUnder(p: string, dir: string): boolean { + const child = normalizePath(path.resolve(p)); + const parent = normalizePath(path.resolve(dir)); + return child !== parent && isPathUnder(child, parent); +} + +/** + * Keep only the outermost manifest of each directory tree. + * + * Elide has no subproject concept: a manifest inside another project's directory (a vendored checkout, a sample, a + * test fixture) is a separate build the enclosing project never invokes. Importing both resolves the inner sources + * twice and produces overlapping content roots in the generated workspace model, so the nested ones are dropped. + * Input order is preserved. + */ +export function outermostManifests(manifestPaths: Iterable): string[] { + const all = [...manifestPaths].map((manifest) => ({ manifest, root: path.dirname(path.resolve(manifest)) })); + const roots: string[] = []; + const kept = new Set(); + // Shallower roots first: an ancestor is always shorter than its descendants, so it is decided before them. + for (const entry of [...all].sort((a, b) => a.root.length - b.root.length)) { + if (roots.some((r) => r === entry.root || isNestedUnder(entry.root, r))) continue; + roots.push(entry.root); + kept.add(entry.manifest); + } + return all.map((e) => e.manifest).filter((m) => kept.has(m)); +} + /** * Whether installed dependencies can be trusted without `elide install`: `.dev/dependencies` exists and the * newest `.dev/elide.lock*.bin` is at least as recent as the manifest. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a46c1c4..2cc208b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,9 @@ export { distributionAt, isLockfileCurrent, isLockfileName, + isNestedUnder, killProcessTree, + outermostManifests, parseClasspath, resolveElideDistribution, type ClasspathUsage, diff --git a/packages/core/test/elide.test.ts b/packages/core/test/elide.test.ts index 92bd7fd..f6e3d24 100644 --- a/packages/core/test/elide.test.ts +++ b/packages/core/test/elide.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { ElideNotFoundError, InvalidElideHomeError, parseClasspath, resolveElideDistribution } from "../src/index.js"; +import { ElideNotFoundError, InvalidElideHomeError, outermostManifests, parseClasspath, resolveElideDistribution } from "../src/index.js"; describe("resolveElideDistribution", () => { const files = new Set(["/custom/elide/bin/elide", "/home/u/.local/share/elide/bin/elide", "/usr/local/bin/elide"]); @@ -27,3 +27,33 @@ describe("parseClasspath", () => { expect(parseClasspath("", "/proj", ":")).toEqual([]); }); }); + +describe("outermostManifests", () => { + test("drops manifests nested inside another project, keeping discovery order", () => { + expect( + outermostManifests([ + "/proj/tools/elide.pkl", + "/proj/elide.pkl", + "/proj/samples/ktjvm/elide.pkl", + "/proj/third_party/vendored/deep/elide.pkl", + ]), + ).toEqual(["/proj/elide.pkl"]); + }); + + test("keeps siblings and prefix-sharing roots", () => { + expect(outermostManifests(["/w/app/elide.pkl", "/w/app-tests/elide.pkl", "/w/lib/elide.pkl"])).toEqual([ + "/w/app/elide.pkl", + "/w/app-tests/elide.pkl", + "/w/lib/elide.pkl", + ]); + }); + + test("an outer manifest discovered last still shadows the inner ones", () => { + expect(outermostManifests(["/w/a/b/elide.pkl", "/w/a/elide.pkl"])).toEqual(["/w/a/elide.pkl"]); + }); + + test("relative and non-normalized paths resolve before comparison", () => { + const cwd = process.cwd(); + expect(outermostManifests(["elide.pkl", `${cwd}/sub/./elide.pkl`])).toEqual(["elide.pkl"]); + }); +}); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index d5f23aa..44b9dac 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to the Elide extension are documented here. The format follo ## [Unreleased] +### Changed + +- Manifests nested inside another Elide project (vendored checkouts, samples, fixtures) are no longer imported as + projects: only the outermost `elide.pkl` of each directory tree is synced, and edits to nested manifests no longer + mark the folder out of date. Deleting an enclosing manifest promotes the manifests it was shadowing. + ## [0.1.0] - 2026-09-06 First release. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 08a12b3..e0dd346 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -11,7 +11,8 @@ Elide's build, run, test, and debug commands are available from the editor. ## Features - **Project sync.** Opening a folder with `elide.pkl` resolves the manifest, installs dependencies when the lockfile is - stale, and reads the compile classpath of every source set. + stale, and reads the compile classpath of every source set. Manifests nested inside another project (vendored + checkouts, samples) are separate builds and are not imported. - **Kotlin/Java code intelligence.** The project model is written as a `workspace.json` the Kotlin LSP imports — modules per source set, libraries with attached sources and javadoc, the selected JDK, and the manifest's Kotlin compiler options. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 17ecdaa..06340b5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -230,7 +230,7 @@ "@types/vscode": "^1.105.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.6.0", - "esbuild": "^0.25.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0" } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 15fc35d..32d1d16 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -51,9 +51,15 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork ); }; - const onStale = (uri: vscode.Uri, what: string) => { - const folder = workspace.locate(uri)?.folder; - if (!folder || workspace.projectsIn(folder).length === 0 || workspace.isSelfInflicted(folder)) return; + /** + * React to a change under a project root. `rootOf` maps the changed file to the directory whose manifest owns it; + * a path that is not a tracked project root belongs to a nested (ignored) manifest and is not a reason to resync. + */ + const onStale = (uri: vscode.Uri, what: string, rootOf: (fsPath: string) => string) => { + const located = workspace.locate(uri); + if (!located) return; + const folder = located.folder; + if (!workspace.projectAt(rootOf(located.fsPath)) || workspace.isSelfInflicted(folder)) return; workspace.markStale(folder); ui.log(`${what} changed: ${uri.fsPath}`); const policy = readConfig(folder).onManifestChange; @@ -62,7 +68,7 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork }; const manifests = vscode.workspace.createFileSystemWatcher(`**/${MANIFEST_NAME}`); - manifests.onDidChange((uri) => onStale(uri, MANIFEST_NAME)); + manifests.onDidChange((uri) => onStale(uri, MANIFEST_NAME, path.dirname)); manifests.onDidCreate((uri) => { const project = workspace.addProject(uri); if (!project) return; @@ -70,17 +76,20 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork ui.setStatus("stale"); schedule(project.folder, "project-added"); }); - manifests.onDidDelete((uri) => { + manifests.onDidDelete(async (uri) => { const project = workspace.removeProject(uri); if (!project) return; ui.log(`project removed: ${project.root}`); + // Manifests that were nested inside the deleted project become projects of their own. + await workspace.discover(project.folder); if (workspace.projectsIn(project.folder).length > 0) void workspace.syncFolder(project.folder, "project-removed"); else ui.setStatus("none"); }); const lockfiles = vscode.workspace.createFileSystemWatcher("**/.dev/elide.lock*"); const onLock = (uri: vscode.Uri) => { - if (isLockfileName(path.basename(uri.fsPath))) onStale(uri, "lockfile"); + // `/.dev/elide.lock*.bin`: two levels up from the lockfile. + if (isLockfileName(path.basename(uri.fsPath))) onStale(uri, "lockfile", (p) => path.dirname(path.dirname(p))); }; lockfiles.onDidChange(onLock); lockfiles.onDidCreate(onLock); diff --git a/packages/vscode/src/projects.ts b/packages/vscode/src/projects.ts index 3e1856f..70be3a7 100644 --- a/packages/vscode/src/projects.ts +++ b/packages/vscode/src/projects.ts @@ -9,6 +9,8 @@ import { ManifestParseError, WORKSPACE_JSON, buildProjectModel, + isNestedUnder, + outermostManifests, resolveElideDistribution, writeKotlinLspWorkspace, type ElideDistribution, @@ -78,14 +80,33 @@ export class ElideWorkspace implements vscode.Disposable { return [...(this.folders.get(folder.uri.toString())?.projects.values() ?? [])]; } + /** The tracked project rooted exactly at `root`; `undefined` for a directory whose manifest is nested and ignored. */ + projectAt(root: string): ElideProject | undefined { + const abs = path.resolve(root); + for (const state of this.folders.values()) { + const project = state.projects.get(abs); + if (project) return project; + } + return undefined; + } + + /** + * Find the projects of `folder`: every `elide.pkl` outside `.dev/` and `node_modules/`, minus the manifests nested + * inside another project's directory, which are separate builds this one does not include. + */ async discover(folder: vscode.WorkspaceFolder): Promise { const found = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, `**/${MANIFEST_NAME}`), DISCOVERY_EXCLUDE); + const manifests = outermostManifests(found.map((uri) => uri.fsPath)); const state = this.folderState(folder); const seen = new Set(); - for (const uri of found) { - const root = path.dirname(uri.fsPath); + for (const manifestPath of manifests) { + const root = path.dirname(manifestPath); seen.add(root); - if (!state.projects.has(root)) state.projects.set(root, { root, manifestPath: uri.fsPath, folder }); + if (!state.projects.has(root)) state.projects.set(root, { root, manifestPath, folder }); + } + if (manifests.length < found.length) { + const kept = new Set(manifests); + for (const uri of found) if (!kept.has(uri.fsPath)) this.ui.log(`ignoring nested manifest: ${uri.fsPath}`); } for (const root of [...state.projects.keys()]) if (!seen.has(root)) state.projects.delete(root); this.changed.fire(); @@ -111,17 +132,30 @@ export class ElideWorkspace implements vscode.Disposable { return undefined; } + /** + * Track a manifest a watcher just reported. Returns `undefined` when the manifest sits inside an existing project + * and is therefore not imported; a manifest that encloses tracked projects takes their trees over. + */ addProject(manifestUri: vscode.Uri): ElideProject | undefined { const located = this.locate(manifestUri); if (!located) return undefined; const state = this.folderState(located.folder); const root = path.dirname(located.fsPath); - let project = state.projects.get(root); - if (!project) { - project = { root, manifestPath: located.fsPath, folder: located.folder }; - state.projects.set(root, project); - this.changed.fire(); + const existing = state.projects.get(root); + if (existing) return existing; + const outer = [...state.projects.keys()].find((other) => isNestedUnder(root, other)); + if (outer) { + this.ui.log(`ignoring nested manifest: ${located.fsPath} (inside ${outer})`); + return undefined; + } + for (const inner of [...state.projects.keys()]) { + if (!isNestedUnder(inner, root)) continue; + state.projects.delete(inner); + this.ui.log(`project dropped: ${inner} is now nested inside ${root}`); } + const project: ElideProject = { root, manifestPath: located.fsPath, folder: located.folder }; + state.projects.set(root, project); + this.changed.fire(); return project; } diff --git a/packages/vscode/test/integration/runner.ts b/packages/vscode/test/integration/runner.ts index 63a9010..7cf7b28 100644 --- a/packages/vscode/test/integration/runner.ts +++ b/packages/vscode/test/integration/runner.ts @@ -44,6 +44,27 @@ writeFileSync( const sample = mkdtempSync(path.join(realpathSync(tmpdir()), "elide-ktjvm-")); cpSync(path.join(repoRoot, "samples", "ktjvm"), sample, { recursive: true, filter: (src) => !src.includes(`${path.sep}.dev`) && !src.endsWith("workspace.json") }); +// A vendored checkout with its own manifest, nested inside the project: a separate build the sample never invokes, +// so it must not appear in workspace.json (the module assertion in index.ts covers it). +mkdirSync(path.join(sample, "vendored", "src", "main", "nested"), { recursive: true }); +writeFileSync(path.join(sample, "vendored", "src", "main", "nested", "Nested.kt"), "package nested\n\nfun vendored() = 1\n"); +writeFileSync( + path.join(sample, "vendored", "elide.pkl"), + `amends "elide:project.pkl" +import "elide:Sources.pkl" as Sources + +name = "vendored-sample" + +sources { + ["main"] = new Sources.SourceSetSpec { + paths { + "src/main/**/*.kt" + } + } +} +`, +); + // A checked-in `.idea` (team members on IntelliJ) must not divert the Kotlin LSP away from workspace.json: the // server's auto-detection picks JPS whenever `.idea/modules.xml` exists, so the extension has to pin the importer. mkdirSync(path.join(sample, ".idea", "modules"), { recursive: true });