From ffec3c3df7b0b77a1a90f166a5dd1e396628e043 Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Sat, 3 Oct 2026 05:50:39 +0300 Subject: [PATCH] vscode: a Download writ button Without writ-lsp the extension highlights and does nothing else, and the only fix it offered was an opam pin typed into a terminal: an OCaml toolchain and minutes of building, for someone who wanted an editor. writ already publishes a release tarball per Linux architecture, with a .sha256 beside it, and the tarball holds everything the extension needs: writ-lsp, the `writ` the verbs run, and the stdlib at share/writ/lib, where the resolver looks relative to the binary. src/download.js fetches the tarball for this platform, verifies its checksum and unpacks it into the extension's global storage, renaming it into place in one step so an interrupted download never counts as an install. It takes the release tagged with this extension's version, or the latest release when that version was never released; the panel's version verdict then says so. A download is the LAST candidate in locate.candidates, after PATH: a writ installed by hand is a choice someone made, and a download is the fallback for having none. For the same reason the panel offers the button only with no server at all, or when the server in use is itself a download. Anywhere else it would fetch an engine that never runs, so those cases get the opam route. The button appears in the panel, in the "no language server" error, and as Writ: Download writ in the Command Palette. After a download the client is restarted through the same reload a changed setting uses, now one function in extension.js. Platforms with no tarball (macOS, Windows) are told to use opam. test/download.test.js covers the tarball names, checksum parsing, what counts as an install and their order, the search-order placement, and when the panel offers the button. WRIT_DOWNLOAD_LIVE=1 also downloads the real release and runs it. Co-Authored-By: Claude Opus 5.5 --- DEVELOPING.md | 4 +- README.md | 17 +++- extension.js | 41 ++++++--- package.json | 5 ++ src/commands.js | 66 ++++++++++++++- src/download.js | 190 ++++++++++++++++++++++++++++++++++++++++++ src/engine.js | 14 +++- src/locate.js | 8 +- src/panel.js | 38 ++++++--- test/download.test.js | 146 ++++++++++++++++++++++++++++++++ 10 files changed, 496 insertions(+), 33 deletions(-) create mode 100644 src/download.js create mode 100644 test/download.test.js diff --git a/DEVELOPING.md b/DEVELOPING.md index e7ef1a7..b8f4766 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -10,6 +10,7 @@ structure — `extension.js` is the wiring, and one module per concern under | | | | --- | --- | | `locate.js` | where the engine is: the candidate order, the checkout a server came from, and the `writ` belonging to it | +| `download.js` | fetching a released writ into the extension's storage: which tarball, the checksum, the unpack | | `watcher.js` | restart when the server binary is replaced | | `staleness.js` | is the built server older than the sources it was built from | | `engine.js` | what engine this editor is talking to, and the verdict on it | @@ -24,7 +25,7 @@ exercise, with nothing stubbed and nothing installed: $ scripts/test.sh ``` -Five files, and each one is there because its subject fails **silently**: +Six files, and each one is there because its subject fails **silently**: | | | | --- | --- | @@ -33,6 +34,7 @@ Five files, and each one is there because its subject fails **silently**: | `test/status.test.js` | the verdict is shown to users as a warning; getting it wrong means telling someone with a good install that it is broken | | `test/manifest.test.js` | nothing typechecks `package.json` — a command contributed but not registered appears in the palette and does nothing | | `test/engine.test.js` | the engine is a separate install — a capability `writ-lsp` stops advertising, or a flag the CLI renames, leaves the extension running and doing nothing. It drives the real server and runs each command line the verbs build; with no `writ-lsp` on PATH it skips, and `WRIT_E2E_REQUIRED=1` makes that a failure | +| `test/download.test.js` | the wrong tarball name is a 404 that reads like an outage, a download ranked ahead of PATH overrides a writ installed on purpose, and a download button shown to someone with `writ-lsp` on PATH fetches an engine that never runs. `WRIT_DOWNLOAD_LIVE=1` also downloads the real release and runs it | ## The version diff --git a/README.md b/README.md index 72bddfc..c78cc1b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,16 @@ to a `writ-lsp` you already have. ## Getting Started -**You need writ too.** The ordinary route needs `writ-lsp` on your `PATH`: +**You need writ too.** On Linux (x86_64 or arm64) the easy route is the +**Download writ** button: in the Writ panel, in the "no language server" +message, or as **Writ: Download writ** in the Command Palette. It fetches the +release matching this extension (or the latest one, if that version was never +released), verifies its checksum, unpacks it into the extension's own storage +and starts it. Nothing goes on your `PATH`, and uninstalling the extension +removes it. + +A writ you install yourself always wins over a downloaded one. The usual route +puts `writ-lsp` on your `PATH`: ```console $ opam pin add writ git+https://github.com/writ-lang/writ.git # or unpack a release tarball @@ -111,9 +120,9 @@ want different fixes: | It says | What happened | The button | | ----------------------------------------- | ----------------------------------------------- | -------------------- | -| no language server | nothing was found at any candidate path | build, or update writ| +| no language server | nothing was found at any candidate path | download writ, build, or install with opam | | older than `core/…` | the checkout was edited since it was built | build the engine | -| the engine is *x*, the extension is *y* | one of the two was installed on its own | build, or update writ| +| the engine is *x*, the extension is *y* | one of the two was installed on its own | build, update writ, or download a fresh one if the current one was a download | | the server did not say which writ it is | a writ from before the server reported one | update writ | The stale case is the one nothing else catches: a build from before this @@ -127,7 +136,7 @@ was launched and says so when it swaps after a rebuild. | Setting | Default | Description | | ----------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `writ.serverPath` | `_build/default/tooling/lsp/bin/writ_lsp.exe` | A relative path is resolved against each open workspace folder, so the default finds a checkout's own build. If none has it, `writ-lsp` is looked up on `PATH`. An absolute path is used exactly as given. | +| `writ.serverPath` | `_build/default/tooling/lsp/bin/writ_lsp.exe` | A relative path is resolved against each open workspace folder, so the default finds a checkout's own build. If none has it, `writ-lsp` is looked up on `PATH`, and after that a writ downloaded by the extension. An absolute path is used exactly as given. | If nothing exists at any of those, the extension says so and names what it looked for. diff --git a/extension.js b/extension.js index c6f405c..913c1e5 100644 --- a/extension.js +++ b/extension.js @@ -23,10 +23,12 @@ const staleness = require("./src/staleness"); const watcher = require("./src/watcher"); const commands = require("./src/commands"); const panel = require("./src/panel"); +const download = require("./src/download"); let watching; -// Say what is missing, once, and offer the two things that fix it. +// Say what is missing, once, and offer what fixes it — first of all a download, +// where there is a released writ for this platform. // // Silence here would look like a server that starts and answers nothing, which // is the same symptom as a broken server and much harder to chase. The PATH @@ -45,12 +47,16 @@ function reportMissing(tried) { "install writ (`opam pin add writ git+https://github.com/writ-lang/writ.git`, " + "or a release tarball) so " + `\`${locate.INSTALLED}\` is on PATH — or set \`${engine.SETTING}\` to an ` + - "absolute path to the server you want.", + "absolute path to the server you want." + + (download.platformOf() ? " Or download a released writ in one click." : ""), + ...(download.platformOf() ? ["Download writ"] : []), "Open the Writ panel", "Open settings" ) .then((choice) => { - if (choice === "Open the Writ panel") + if (choice === "Download writ") + vscode.commands.executeCommand("writ.downloadEngine"); + else if (choice === "Open the Writ panel") vscode.commands.executeCommand("writ.openOverview"); else if (choice === "Open settings") vscode.commands.executeCommand("writ.openSettings"); @@ -111,22 +117,33 @@ async function start(context) { panel.refresh(); } +// Stop whatever is running and resolve the server again from scratch. +// +// The server may have moved, so everything derived from it is suspect: which +// binary, which version, whether it is stale, and whether it is running at +// all. A changed setting needs this, and so does a fresh download. +async function reload(context) { + if (watching) watching.dispose(); + watching = undefined; + const client = engine.current().client; + if (client) await client.stop().catch(() => {}); + engine.attach(null); + await start(context); +} + function activate(context) { + // Downloaded engines live in the extension's own storage, which VS Code + // keeps across updates of the extension and removes when it is uninstalled. + engine.setStorage(context.globalStorageUri.fsPath); panel.register(context); - commands.register(context); + commands.register(context, { reload: () => reload(context) }); context.subscriptions.push( - // The server may have moved, so everything derived from it is suspect: which - // binary, which version, whether it is stale, and whether it is running at - // all. Reloading the window used to be the only way to pick up a corrected + // Reloading the window used to be the only way to pick up a corrected // setting. vscode.workspace.onDidChangeConfiguration(async (event) => { if (!event.affectsConfiguration("writ")) return; - if (watching) watching.dispose(); - const client = engine.current().client; - if (client) await client.stop().catch(() => {}); - engine.attach(null); - await start(context); + await reload(context); }) ); diff --git a/package.json b/package.json index 2092199..e3c4494 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,11 @@ "command": "writ.update", "title": "Writ: Update writ…" }, + { + "command": "writ.downloadEngine", + "title": "Writ: Download writ", + "icon": "$(cloud-download)" + }, { "command": "writ.openOverview", "title": "Writ: Open the Writ Panel" diff --git a/src/commands.js b/src/commands.js index 0994bde..f307fde 100644 --- a/src/commands.js +++ b/src/commands.js @@ -21,6 +21,7 @@ const fs = require("fs"); const path = require("path"); const engine = require("./engine"); +const download = require("./download"); const staleness = require("./staleness"); const panel = require("./panel"); @@ -320,7 +321,67 @@ function update() { t.sendText(PIN, false); // NOT executed: an install command is the user's to press enter on } -function register(context) { +// Download a released writ into the extension's storage, then start it. +// +// The one-click route for someone with no opam and no checkout. What it fetches +// and how it checks it is src/download.js; this is the progress, the result, +// and the restart. `reload` is extension.js's, because only it can start the +// client again. +async function downloadEngine(context, reload) { + const vscode = require("vscode"); + const storage = engine.current().storage; + const wanted = + (context.extension && + context.extension.packageJSON && + context.extension.packageJSON.version) || + "0.0.0"; + if (!download.platformOf()) { + const choice = await vscode.window.showWarningMessage( + `Writ: there is no released writ for ${process.platform}-${process.arch} ` + + "to download. Install it with opam instead.", + "Install with opam…" + ); + if (choice) update(); + return; + } + try { + const got = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Writ", + cancellable: false, + }, + (progress) => + download.download({ + storage, + wanted, + say: (m) => progress.report({ message: m }), + }) + ); + await reload(); + const s = engine.current(); + if (s.serverPath !== got.server) { + // Downloaded, but something ahead of it in the search order still wins: + // a checkout's build, writ-lsp on PATH, or an absolute setting. + vscode.window.showInformationMessage( + `Writ: downloaded writ ${got.version}, but the server in use is still ` + + `${s.serverPath}, which comes first in the search order.` + ); + } else { + vscode.window.showInformationMessage( + `Writ: writ ${got.version} downloaded and running.` + ); + } + } catch (e) { + const choice = await vscode.window.showErrorMessage( + `Writ: could not download writ: ${e.message}`, + "Install with opam…" + ); + if (choice) update(); + } +} + +function register(context, hooks = {}) { const vscode = require("vscode"); const on = (name, fn) => context.subscriptions.push(vscode.commands.registerCommand(name, fn)); @@ -331,6 +392,9 @@ function register(context) { on("writ.compare", compare); on("writ.build", build); on("writ.update", update); + on("writ.downloadEngine", () => + downloadEngine(context, hooks.reload || (() => Promise.resolve())) + ); on("writ.restartServer", async () => { await engine.restart(); diff --git a/src/download.js b/src/download.js new file mode 100644 index 0000000..5e981f1 --- /dev/null +++ b/src/download.js @@ -0,0 +1,190 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Fetch a released writ into the extension's own storage, so a user with no +// opam and no checkout gets a working editor from one button. +// +// The release tarball is the unit, not a lone `writ-lsp`. It carries the +// server, the `writ` the verbs run, and the standard library at +// share/writ/lib, which is where the resolver looks relative to the binary. So +// once unpacked, locate.cliFor finds the CLI beside the server and +// `(load "stdlib.writ")` resolves, with nothing configured. +// +// WHICH RELEASE. The extension and the engine are released together, so the +// one to fetch is the tag matching this extension's version. When that tag has +// no release, the latest release is used and the panel's version verdict says +// so. A newer engine is a better answer than no engine. +// +// VERIFIED BEFORE IT RUNS. Every tarball ships with a .sha256 beside it, and a +// download whose bytes do not match is deleted rather than unpacked. +// +// Plain node: no `vscode` here, so every part except the network is checked +// under node by test/download.test.js. + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { execFile } = require("child_process"); + +const REPO = "writ-lang/writ"; + +// node's platform/arch to the names `make release` gives its tarballs +// (`uname -s | tr A-Z a-z` and `uname -m`). Only what is published is listed: +// a platform missing here has no tarball, and the caller says so rather than +// trying a URL that cannot exist. +const PLATFORMS = { + "linux-x64": { os: "linux", arch: "x86_64" }, + "linux-arm64": { os: "linux", arch: "aarch64" }, +}; + +function platformOf(platform = process.platform, arch = process.arch) { + return PLATFORMS[`${platform}-${arch}`] || null; +} + +// The tarball's base name, which is also the directory it unpacks to. +function assetBase(version, plat) { + return `writ-${version}-${plat.os}-${plat.arch}`; +} + +function assetUrl(tag, file) { + return `https://github.com/${REPO}/releases/download/${tag}/${file}`; +} + +// A .sha256 file is `HEX NAME`, as sha256sum writes it. +function parseChecksum(text) { + const m = /^([0-9a-f]{64})\b/i.exec(String(text).trim()); + return m ? m[1].toLowerCase() : null; +} + +function sha256(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +// Released engines already unpacked under `storage`, newest first. A +// directory counts only when its server is actually there: an interrupted +// unpack is not an install. +function installed(storage) { + let entries; + try { + entries = fs.readdirSync(storage, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory() && /^writ-\d/.test(e.name)) + .map((e) => ({ + version: e.name.replace(/^writ-/, "").replace(/-[a-z]+-[a-z0-9_]+$/, ""), + server: path.join(storage, e.name, "bin", "writ-lsp"), + })) + .filter((x) => fs.existsSync(x.server)) + .sort((a, b) => compareVersions(b.version, a.version)); +} + +function compareVersions(a, b) { + const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0); + const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + +// ── the network ───────────────────────────────────────────────────────────── + +async function exists(url) { + const r = await fetch(url, { method: "HEAD", redirect: "follow" }); + return r.ok; +} + +// The tag to fetch: this extension's own version if it was released, else the +// latest release. Returns { tag, version }. +async function chooseRelease(wanted, plat) { + const tag = `v${wanted}`; + if (await exists(assetUrl(tag, assetBase(wanted, plat) + ".tar.gz"))) + return { tag, version: wanted }; + const r = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!r.ok) throw new Error(`could not ask GitHub for the latest release (${r.status})`); + const latest = (await r.json()).tag_name; + return { tag: latest, version: latest.replace(/^v/, "") }; +} + +async function fetchTo(url, file) { + const r = await fetch(url, { redirect: "follow" }); + if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`); + fs.writeFileSync(file, Buffer.from(await r.arrayBuffer())); +} + +function untar(archive, into) { + return new Promise((resolve, reject) => + execFile("tar", ["xzf", archive, "-C", into], (e, _out, err) => + e ? reject(new Error(`tar: ${String(err).trim() || e.message}`)) : resolve() + ) + ); +} + +// Download, verify and unpack a release into `storage`. Returns +// { version, server }. `say` reports progress in a few words. +async function download({ storage, wanted, say = () => {} }) { + const plat = platformOf(); + if (!plat) + throw new Error( + `no released writ for ${process.platform}-${process.arch}; ` + + "install it with opam instead" + ); + say("finding the release"); + const { tag, version } = await chooseRelease(wanted, plat); + const base = assetBase(version, plat); + const final = path.join(storage, base); + const server = path.join(final, "bin", "writ-lsp"); + if (fs.existsSync(server)) return { version, server }; + + fs.mkdirSync(storage, { recursive: true }); + // Unpacked INSIDE storage so the last step is a rename on one filesystem, + // which is atomic. A dot-name is never mistaken for an install by + // [installed]. + const work = fs.mkdtempSync(path.join(storage, ".download-")); + try { + const tarball = path.join(work, base + ".tar.gz"); + say(`downloading writ ${version}`); + await fetchTo(assetUrl(tag, base + ".tar.gz"), tarball); + await fetchTo(assetUrl(tag, base + ".tar.gz.sha256"), tarball + ".sha256"); + + say("verifying the checksum"); + const want = parseChecksum(fs.readFileSync(tarball + ".sha256", "utf8")); + const got = sha256(tarball); + if (!want || want !== got) + throw new Error(`checksum mismatch for ${base}.tar.gz: expected ${want}, got ${got}`); + + say("unpacking"); + await untar(tarball, work); + const unpacked = path.join(work, base); + if (!fs.existsSync(path.join(unpacked, "bin", "writ-lsp"))) + throw new Error(`${base}.tar.gz has no bin/writ-lsp`); + for (const exe of ["writ", "writ-lsp", "writ-mcp"]) { + const p = path.join(unpacked, "bin", exe); + if (fs.existsSync(p)) fs.chmodSync(p, 0o755); + } + // Into place in one step, so `installed` never sees half an engine. + fs.rmSync(final, { recursive: true, force: true }); + fs.renameSync(unpacked, final); + return { version, server }; + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +} + +module.exports = { + REPO, + platformOf, + assetBase, + assetUrl, + parseChecksum, + sha256, + installed, + compareVersions, + chooseRelease, + download, +}; diff --git a/src/engine.js b/src/engine.js index bca6c12..2dba1ea 100644 --- a/src/engine.js +++ b/src/engine.js @@ -14,6 +14,7 @@ const locate = require("./locate"); const staleness = require("./staleness"); +const download = require("./download"); const SETTING = "writ.serverPath"; @@ -24,8 +25,14 @@ const state = { cli: null, // the `writ` command line belonging to that same install version: null, // what its `initialize` reported, or null client: null, + storage: null, // where downloaded engines live (the extension's global storage) + downloaded: false, // whether serverPath is one of them }; +function setStorage(dir) { + state.storage = dir; +} + function configured() { const vscode = require("vscode"); return vscode.workspace.getConfiguration().get(SETTING, locate.DEFAULT_SERVER); @@ -40,8 +47,12 @@ function folders() { // caller does not have to fetch it separately. function resolve() { const fs = require("fs"); - state.tried = locate.candidates(configured(), folders()); + const fetched = state.storage + ? download.installed(state.storage).map((x) => x.server) + : []; + state.tried = locate.candidates(configured(), folders(), fetched); state.serverPath = state.tried.find((p) => fs.existsSync(p)) || null; + state.downloaded = !!state.serverPath && fetched.includes(state.serverPath); state.checkout = state.serverPath ? locate.checkoutOf(state.serverPath) : null; state.cli = state.serverPath ? locate.cliFor(state.serverPath) : null; state.version = null; @@ -144,6 +155,7 @@ function restart() { module.exports = { SETTING, + setStorage, resolve, current, readVersion, diff --git a/src/locate.js b/src/locate.js index 9a27280..7a124b7 100644 --- a/src/locate.js +++ b/src/locate.js @@ -77,9 +77,13 @@ function checkoutsBelow(dir) { // An ABSOLUTE setting is taken literally and nothing is guessed after it: an // operator who names a server means that server. // +// LAST, engines the extension downloaded itself (src/download.js), newest +// first. After PATH on purpose: a writ someone installed by hand is a choice +// they made, and a download is only the fallback for having none. +// // `folders` is passed in rather than read from `vscode` so the order can be // checked under plain node, which is what test/resolve.test.js does. -function candidates(configured, folders) { +function candidates(configured, folders, downloaded = []) { if (path.isAbsolute(configured)) return [configured]; // The folder itself before anything under it: opening the checkout directly // is the common case and must not be slowed down or second-guessed. @@ -87,7 +91,7 @@ function candidates(configured, folders) { path.join(f, configured), ...checkoutsBelow(f).map((d) => path.join(d, configured)), ]); - return [...inWorkspace, ...onPath(INSTALLED)]; + return [...inWorkspace, ...onPath(INSTALLED), ...downloaded]; } // The checkout a resolved server came out of, or null for an installed one. diff --git a/src/panel.js b/src/panel.js index b491698..192dbc6 100644 --- a/src/panel.js +++ b/src/panel.js @@ -29,6 +29,7 @@ // language a person has to hold in their head, and the panel is already open. const engine = require("./engine"); +const download = require("./download"); const GUIDE = "https://github.com/writ-lang/writ-vscode#readme"; const LANGUAGE = "https://github.com/writ-lang/writ#readme"; @@ -56,22 +57,36 @@ function escapeHtml(s) { // The action button under the status line, if the verdict asks for one. // Named here rather than in [engine.verdict] so that the verdict stays a // statement about the install and not a piece of user interface. -function actionButton(action, checkout) { - if (action === "build") - return ''; - if (action === "install") +// +// DOWNLOAD is offered only where it would take effect: with no server at all, +// or when the server in use is itself a download. A downloaded engine comes +// after PATH in the search order, so offering it to someone whose writ-lsp is +// on PATH would fetch an engine that never runs. They get the opam route. +function actionButton(action, checkout, downloadable) { + const button = (cmd, label) => + ``; + if (action === "build" || (action === "install" && checkout)) + return button("writ.build", "Build the engine"); + if (action === "install" && downloadable) return ( - '" + button("writ.downloadEngine", "Download writ") + + '\n ' ); + if (action === "install") return button("writ.update", "Update writ…"); return ""; } // Pure, so the whole panel can be rendered and inspected under plain node — // which test/panel.test.js does, because a webview is otherwise only checkable // by looking at it. -function renderHtml({ extension, engineVersion, serverPath, checkout, stale }) { +function renderHtml({ + extension, + engineVersion, + serverPath, + checkout, + stale, + downloadable = false, +}) { const v = engine.verdict({ extension, engine: engineVersion, @@ -152,7 +167,7 @@ function renderHtml({ extension, engineVersion, serverPath, checkout, stale }) {
${escapeHtml(serverPath || "no language server")}

${status}

- ${v ? actionButton(v.action, checkout) : ""} + ${v ? actionButton(v.action, checkout, downloadable) : ""} @@ -165,9 +180,6 @@ function renderHtml({ extension, engineVersion, serverPath, checkout, stale }) { const api = acquireVsCodeApi(); for (const b of document.querySelectorAll('button[data-cmd]')) b.addEventListener('click', () => api.postMessage({ command: b.dataset.cmd })); - const act = document.getElementById('act'); - if (act) act.addEventListener('click', - () => api.postMessage({ command: '${v && v.action === "build" ? "writ.build" : "writ.update"}' })); document.getElementById('guide') .addEventListener('click', () => api.postMessage({ open: '${GUIDE}' })); document.getElementById('language') @@ -206,6 +218,8 @@ function register(context) { serverPath: s.serverPath, checkout: s.checkout, stale: engine.stale(), + downloadable: + !!download.platformOf() && (!s.serverPath || s.downloaded), }); }; live = paint; diff --git a/test/download.test.js b/test/download.test.js new file mode 100644 index 0000000..e67e61c --- /dev/null +++ b/test/download.test.js @@ -0,0 +1,146 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The one-click download: which tarball, how it is checked, where the result +// sits in the search order, and when the panel offers it. +// +// Each of these fails quietly. The wrong tarball name is a 404 that reads like +// "GitHub is down". A checksum that is parsed wrong either refuses every +// download or accepts any. A downloaded engine placed AHEAD of PATH would +// silently override a writ someone installed on purpose, and a download button +// shown to someone whose writ-lsp is on PATH would fetch an engine that never +// runs. +// +// No network by default. WRIT_DOWNLOAD_LIVE=1 also downloads the real release +// into a temporary directory and runs it. +// +// Run: node test/download.test.js + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { suite } = require("./harness"); +const d = require("../src/download"); +const locate = require("../src/locate"); +const panel = require("../src/panel"); + +const { check, done } = suite("download tests"); + +// ── which tarball ──────────────────────────────────────────────────────────── +// The names `make release` gives: uname's spelling, not node's. +check("linux x64 is x86_64", d.platformOf("linux", "x64")?.arch === "x86_64"); +check("linux arm64 is aarch64", d.platformOf("linux", "arm64")?.arch === "aarch64"); +check("macOS has no tarball yet", d.platformOf("darwin", "arm64") === null); +check("nor does Windows", d.platformOf("win32", "x64") === null); +const plat = d.platformOf("linux", "x64"); +check( + "the asset is named as the release names it", + d.assetBase("0.2.0", plat) === "writ-0.2.0-linux-x86_64" +); +check( + "and is fetched from the tag's release", + d.assetUrl("v0.2.0", "writ-0.2.0-linux-x86_64.tar.gz") === + "https://github.com/writ-lang/writ/releases/download/v0.2.0/writ-0.2.0-linux-x86_64.tar.gz" +); + +// ── the checksum ───────────────────────────────────────────────────────────── +const hex = "ab".repeat(32); +check("a sha256sum line parses", d.parseChecksum(`${hex} writ.tar.gz\n`) === hex); +check("upper case is normalised", d.parseChecksum(hex.toUpperCase() + " x") === hex); +check("a short hash is refused", d.parseChecksum("abc writ.tar.gz") === null); +check("so is an empty file", d.parseChecksum("") === null); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "writ-dl-test-")); +fs.writeFileSync(path.join(tmp, "f"), "writ"); +// `printf writ | sha256sum` +check( + "sha256 of a file is the known digest", + d.sha256(path.join(tmp, "f")) === + "bd998adfd46eaff944ee5125a9ffaff2ecbf67d11ee8a2a3d2afd0946bc8adbb" +); + +// ── what is installed ──────────────────────────────────────────────────────── +const storage = path.join(tmp, "storage"); +const fake = (dir) => { + fs.mkdirSync(path.join(storage, dir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(storage, dir, "bin", "writ-lsp"), ""); +}; +fake("writ-0.2.0-linux-x86_64"); +fake("writ-0.10.0-linux-x86_64"); +fs.mkdirSync(path.join(storage, "writ-0.3.0-linux-x86_64"), { recursive: true }); // interrupted +fs.mkdirSync(path.join(storage, ".download-abc", "bin"), { recursive: true }); // in progress +const found = d.installed(storage); +check("only complete installs count", found.length === 2); +check( + "newest first, compared as numbers (0.10 after 0.2)", + found[0] && found[0].version === "0.10.0" && found[1].version === "0.2.0" +); +check("a missing storage directory is no installs", d.installed(path.join(tmp, "none")).length === 0); + +// ── where a download sits in the search order ─────────────────────────────── +const dl = ["/s/writ-0.2.0-linux-x86_64/bin/writ-lsp"]; +const order = locate.candidates(locate.DEFAULT_SERVER, ["/w/writ"], dl); +const onPath = locate.onPath(locate.INSTALLED); +check("a download is the LAST candidate", order[order.length - 1] === dl[0]); +check( + "after every PATH entry, so a hand-installed writ wins", + order.indexOf(dl[0]) > Math.max(...onPath.map((p) => order.indexOf(p))) +); +check( + "an absolute setting still means exactly that server", + locate.candidates("/opt/writ-lsp", ["/w/writ"], dl).join() === "/opt/writ-lsp" +); +check("no downloads changes nothing", locate.candidates(locate.DEFAULT_SERVER, []).length === onPath.length); +check( + "the CLI of a download is found beside its server", + (() => { + const server = path.join(storage, "writ-0.2.0-linux-x86_64", "bin", "writ-lsp"); + fs.writeFileSync(path.join(path.dirname(server), "writ"), ""); + return locate.cliFor(server) === path.join(path.dirname(server), "writ"); + })() +); + +// ── when the panel offers it ───────────────────────────────────────────────── +const html = (o) => + panel.renderHtml({ extension: "0.2.0", engineVersion: null, checkout: null, stale: null, ...o }); +const none = html({ serverPath: null, downloadable: true }); +check("no server: the panel offers the download", none.includes('data-cmd="writ.downloadEngine"')); +check("with opam as the second choice", none.includes('data-cmd="writ.update"')); +check( + "on a platform with no tarball it does not", + !html({ serverPath: null, downloadable: false }).includes("writ.downloadEngine") +); +const onPathMismatch = html({ serverPath: "/usr/bin/writ-lsp", engineVersion: "0.1.0" }); +check( + "a mismatched writ on PATH is not offered a download it would ignore", + !onPathMismatch.includes("writ.downloadEngine") && onPathMismatch.includes("writ.update") +); +check( + "a mismatched download is offered a fresh one", + html({ serverPath: dl[0], engineVersion: "0.1.0", downloadable: true }).includes( + "writ.downloadEngine" + ) +); +check( + "every action button carries the command it runs", + !/