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 }) {
${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", + !/