diff --git a/README.md b/README.md index 802cb87..b9c363c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ The Filen CLI includes an automatic updater that checks for a new release every Invoke the CLI with the `--skip-update` flag to skip checking for updates. (Use the `--force-update` flag to check for updates even if it was recently checked.) +You can always install any version using `filen install `, `filen install latest` or `filen install canary`. + +### Canary releases + +If you want to be among the first to try out new features and fixes, you can enable canary releases, +which are early releases meant for a subset of users to test before they are declared as stable. +To enable or disable canary releases, invoke the CLI with the command `filen canary`. + # Usage diff --git a/package-lock.json b/package-lock.json index 3b81360..3182ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dedent": "^1.5.3", "open": "^7.4.2", "read": "^4.0.0", + "semver": "^7.6.3", "uuid-by-string": "^4.0.0" }, "devDependencies": { @@ -8038,7 +8039,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 3c1100d..9be2a75 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dedent": "^1.5.3", "open": "^7.4.2", "read": "^4.0.0", + "semver": "^7.6.3", "uuid-by-string": "^4.0.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index fe3adc2..af23ffe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,8 +88,27 @@ export const isDevelopment = args["--dev"] ?? false // check for updates if (args["--skip-update"] !== true) { + const updater = new Updater() + if (args["_"][0] === "canary") { + try { + await updater.showCanaryPrompt() + process.exit() + } catch (e) { + errExit("change canary preferences", e) + } + } + if (args["_"][0] === "install") { + try { + const version = args["_"][1] + if (version === undefined) errExit("Need to specify version") + await updater.fetchAndInstallVersion(version) + process.exit() + } catch (e) { + errExit("install version", e) + } + } try { - await new Updater().checkForUpdates(args["--force-update"] ?? false) + await updater.checkForUpdates(args["--force-update"] ?? false) } catch (e) { errExit("check for updates", e) } diff --git a/src/interface/helpPage.ts b/src/interface/helpPage.ts index 8fdfe0b..a0a298e 100644 --- a/src/interface/helpPage.ts +++ b/src/interface/helpPage.ts @@ -18,6 +18,7 @@ export class HelpPage { if (topic === "webdav") return this.webdavHelpPage if (topic === "s3") return this.s3HelpPage if (topic === "mount") return this.driveMountingHelpPage + if (topic.startsWith("update")) return this.updatesHelpPage return undefined } @@ -81,8 +82,6 @@ export class HelpPage { ["--password ", ""], ["--two-factor-code , -c ", "(optional)"], ["--log-file ", "write logs to a file"], - ["--skip-update", "skip checking for updates"], - ["--force-update", "check for updates even if it was recently checked"], ])} View the topic pages via \`filen -h \` for more information: @@ -93,6 +92,7 @@ export class HelpPage { ["mount", "Mount a network drive"], ["webdav", "WebDAV mirror server with single user or proxy mode"], ["s3", "S3 mirror server"], + ["updates", "Fetching and installing updates"], ])} Read the full documentation at: https://github.com/FilenCloudDienste/filen-cli${this.versionUrlSegment}#readme @@ -203,4 +203,20 @@ export class HelpPage { Read the full documentation at: https://github.com/FilenCloudDienste/filen-cli${this.versionUrlSegment}#s3-server ` + + private readonly updatesHelpPage: string = dedent` + The automatic updater checks for new releases every time the CLI is invoked. + + After checking for updates, it will not check again for the next 10 minutes. Use the flags: + --force-update to check for updates even if it was recently checked. + --skip-update to skip checking for updates. + + You can always install any version using \`filen install \`, \`filen install latest\` or \`filen install canary\`. + + If you want to be among the first to try out new features and fixes, you can enable canary releases, + which are early releases meant for a subset of users to test before they are declared as stable. + To enable or disable canary releases, invoke the CLI with the command \`filen canary\`. + + Read the full documentation at: https://github.com/FilenCloudDienste/filen-cli${this.versionUrlSegment}#installation-and-updates + ` } \ No newline at end of file diff --git a/src/interface/interface.ts b/src/interface/interface.ts index cc4835b..4f424dc 100644 --- a/src/interface/interface.ts +++ b/src/interface/interface.ts @@ -193,9 +193,28 @@ process.stdin.on("keypress", () => { * @param action The action to include in the prompt (e.g. "delete file.txt"), or undefined for a generic prompt. */ export async function promptConfirm(action: string | undefined) { + return promptYesNo(action !== undefined ? `Are you sure you want to ${action}?` : "Are you sure?") +} + +/** + * Global confirmation prompting method + * @param question The question to include in the prompt + * @param defaultAnswer The default answer if there's no input + */ +export async function promptYesNo(question: string, defaultAnswer: boolean = false) { return new Promise((resolve) => { - prompt(action !== undefined ? `Are you sure you want to ${action}? [y/N] ` : "Are you sure? [y/N] ").then(result => { - resolve(result.toLowerCase() === "y") + prompt(`${question} ${defaultAnswer ? "[Y/n]" : "[y/N]"} `).then(result => { + const input = result.toLowerCase() + if (input === "n" || input === "no") { + resolve(false) + } else if (input === "y" || input === "yes") { + resolve(true) + } else if (input.trim() === "") { + resolve(defaultAnswer) + } else { + err("Invalid input, please enter 'y' or 'n'!") + promptYesNo(question, defaultAnswer).then(resolve) + } }) }) } diff --git a/src/updater.ts b/src/updater.ts index 37d3633..50c277f 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -1,12 +1,21 @@ import { disableAutomaticUpdates, version } from "./buildInfo" -import { err, errExit, out, outVerbose, prompt } from "./interface/interface" +import { err, errExit, out, outVerbose, promptYesNo } from "./interface/interface" import path from "path" import { spawn } from "node:child_process" import { downloadFile, exists, platformConfigPath } from "./util/util" import * as fs from "node:fs" +import semver from "semver/preload" + +type UpdateCache = { + lastCheckedUpdate: number + canary: boolean +} type ReleaseInfo = { + id: number tag_name: string + prerelease: boolean + body: string assets: { name: string browser_download_url: string @@ -14,7 +23,7 @@ type ReleaseInfo = { } /** - * Checks for updates and installs updates. + * Manages updates. */ export class Updater { private readonly updateCacheDirectory = platformConfigPath() @@ -30,78 +39,219 @@ export class Updater { return } + const updateCache = await this.readUpdateCache() + // skip if already recently checked if (!force) { - if (await exists(this.updateCacheFile)) { - try { - const content = (await fs.promises.readFile(this.updateCacheFile)).toString() - const updateCache = (() => { - try { - return JSON.parse(content) - } catch (e) { - throw new Error("unable to parse update cache file") - } - })() - if (typeof updateCache.lastCheckedUpdate !== "number") throw new Error("malformed update cache file") - if (Date.now() - updateCache.lastCheckedUpdate < this.updateCheckExpiration) { - outVerbose("Checked for updates not long ago, not checking again") - return - } else { - outVerbose("Last update check is too long ago, checking again") - } - } catch (e) { - err("read recent update checks", e, "invoke the CLI again to retry updating") - try { - await fs.promises.rm(this.updateCacheFile) - } catch (e) { - errExit("delete update cache file", e) + if (Date.now() - updateCache.lastCheckedUpdate < this.updateCheckExpiration) { + outVerbose("Checked for updates not long ago, not checking again") + return + } else { + outVerbose("Last update check is too long ago, checking again") + } + } else { + outVerbose("Update check forced") + } + + const { releases, latestRelease, canaryRelease } = await this.fetchReleaseInfo() + const latestDownloadUrl = this.getDownloadUrl(latestRelease) + const canaryDownloadUrl = this.getDownloadUrl(canaryRelease) + + const currentVersion = version + const latestVersion = latestRelease.tag_name + + if (disableAutomaticUpdates && latestDownloadUrl !== undefined && currentVersion !== latestVersion) { + // don't prompt for update in a container environment + out(`${(semver.gt(latestVersion, currentVersion) ? "Update available" : "Other version recommended")}: ${currentVersion} -> ${latestVersion}`) + return + } + if (releases.filter(release => release.tag_name === currentVersion).length === 0) { + // current version doesn't exist as release, so it was intentionally deleted to prompt for downgrade + if (updateCache.canary && canaryDownloadUrl !== undefined) { + if (await promptYesNo(`It is highly recommended to ${semver.gt(currentVersion, canaryRelease.tag_name) ? "downgrade" : "update"} from ${currentVersion} to${latestVersion === canaryRelease.tag_name ? "" : " canary release"} ${canaryRelease.tag_name}. Please confirm:`, true)) { + await this.showChangelogs(releases, canaryRelease.tag_name) + await this.installVersion(currentVersion, canaryRelease.tag_name, canaryDownloadUrl) + } + } else if (latestDownloadUrl !== undefined) { + if (await promptYesNo(`It is highly recommended to ${semver.gt(currentVersion, latestVersion) ? "downgrade" : "update"} from ${currentVersion} to ${latestVersion}. Please confirm:`, true)) { + await this.showChangelogs(releases, latestVersion) + await this.installVersion(currentVersion, latestVersion, latestDownloadUrl) + } + } else { + out("It is highly recommended to update to a newer version, but none could be found. Please try to reinstall the CLI.") + } + return + } + if (semver.gt(latestVersion, currentVersion) && latestDownloadUrl !== undefined) { + if (await promptYesNo(`Update from ${currentVersion} to ${latestVersion}?`)) { + await this.showChangelogs(releases, latestVersion) + await this.installVersion(currentVersion, latestVersion, latestDownloadUrl) + } + } else { + if (updateCache.canary) { + if (canaryDownloadUrl !== undefined && semver.gt(canaryRelease.tag_name, currentVersion)) { + if (await promptYesNo(`Update from ${currentVersion} to canary release ${canaryRelease.tag_name}?`)) { + await this.showChangelogs(releases, canaryRelease.tag_name) + await this.installVersion(currentVersion, canaryRelease.tag_name, canaryDownloadUrl) } - return + } else { + outVerbose(`${currentVersion} is up to date.`) + } + } else { + if (semver.gt(currentVersion, latestVersion)) { + // this canary version seems to have been downloaded manually + out(`It seems you have downloaded this canary release ${currentVersion} manually (latest is ${latestVersion}).\n` + + "Please invoke `filen canary` to remove this warning and get notified of new canary releases.\n" + + "If this wasn't intentional, invoke `filen install latest` to install the latest version.") + } else { + outVerbose(`${currentVersion} is up to date.`) } } + } + + // save update cache + await this.writeUpdateCache({ ...updateCache, lastCheckedUpdate: Date.now() }) + } + + /** + * Shows a prompt to the user to enable or disable canary releases. + */ + public async showCanaryPrompt() { + const updateCache = await this.readUpdateCache() + if (updateCache.canary) { + out("Canary releases are enabled.") + if (await promptYesNo("Disable canary releases?", false)) { + await this.writeUpdateCache({ ...updateCache, canary: false }) + out("Canary releases disabled. If you wish to rollback to the latest stable version, invoke `filen install latest`.") + } } else { - outVerbose("Update check forced") + out("You are about to enable canary releases, which are early releases meant for a subset of users to test before they are declared as stable.\n" + + "You might encounter bugs or crashes, so please do not use this in production. Report bugs on GitHub: https://github.com/FilenCloudDienste/filen-cli/issues\n" + + "To install the latest stable version again, invoke the CLI with the command `filen install latest`. To disable canary releases altogether, call `filen canary` again.") + if (await promptYesNo("Enable canary releases?")) { + await this.writeUpdateCache({ ...updateCache, canary: true }) + out("Canary releases enabled.") + + const { releases, canaryRelease } = await this.fetchReleaseInfo() + const downloadUrl = this.getDownloadUrl(canaryRelease) + if (semver.gt(canaryRelease.tag_name, version) && downloadUrl !== undefined) { + if (await promptYesNo(`Install the latest canary release ${canaryRelease.tag_name} now?`)) { + await this.showChangelogs(releases, canaryRelease.tag_name) + await this.installVersion(version, canaryRelease.tag_name, downloadUrl) + } + } + } } + } - const releaseInfoResponse = await (async () => { - try { - return await fetch("https://api.github.com/repos/FilenCloudDienste/filen-cli/releases/latest") - } catch (e) { - errExit("fetch update info", e) + /** + * Downloads and installs any specified version. + * @param version The specific version to install, or "latest" or "canary". + */ + public async fetchAndInstallVersion(version: string | "latest" | "canary") { + const { releases, latestRelease, canaryRelease } = await this.fetchReleaseInfo() + const release = (() => { + switch (version) { + case "latest": return latestRelease + case "canary": return canaryRelease + default: return releases.find(release => release.tag_name === version) } })() - const releaseInfo: ReleaseInfo = await releaseInfoResponse.json() + if (release === undefined) errExit(`No such version: ${version}`) + const downloadUrl = this.getDownloadUrl(release) + if (downloadUrl === undefined) errExit(`Unsupported platform ${process.platform} for version ${release.tag_name}`) + if (await promptYesNo(`Download and install ${release.tag_name}?`)) { + await this.installVersion(version, release.tag_name, downloadUrl) + } + } - const currentVersion = version - const publishedVersion = releaseInfo.tag_name - - let platformStr = "linux" - if (process.platform === "win32") platformStr = "win" - if (process.platform === "darwin") platformStr = "macos" - const downloadUrl = releaseInfo.assets.find(asset => asset.name.includes(platformStr) && asset.name.includes(process.arch))?.browser_download_url ?? undefined - if (downloadUrl !== undefined && currentVersion !== publishedVersion) { - if (disableAutomaticUpdates) { - out(`Update available: ${currentVersion} -> ${publishedVersion}`) - return + // update cache + + private async readUpdateCache(): Promise { + if (await exists(this.updateCacheFile)) { + try { + const content = await (async () => { + try { + return (await fs.promises.readFile(this.updateCacheFile)).toString() + } catch (e) { + throw new Error("unable to read update cache file") + } + })() + const updateCache = (() => { + try { + return JSON.parse(content) + } catch (e) { + throw new Error("unable to parse update cache file") + } + })() + if (typeof updateCache.lastCheckedUpdate !== "number") throw new Error("malformed update cache file") + return { + lastCheckedUpdate: updateCache.lastCheckedUpdate, + canary: updateCache.canary ?? false + } + } catch (e) { + err("read recent update checks", e, "invoke the CLI again to retry updating") + try { + await fs.promises.rm(this.updateCacheFile) + } catch (e) { + errExit("delete update cache file", e) + } } + } + return { + lastCheckedUpdate: 0, + canary: false + } + } + + private async writeUpdateCache(updateCache: UpdateCache) { + if (!await exists(this.updateCacheDirectory)) { + await fs.promises.mkdir(this.updateCacheDirectory) + } + await fs.promises.writeFile(this.updateCacheFile, JSON.stringify(updateCache)) + } + + // release info - if ((await prompt(`Update from ${currentVersion} to ${publishedVersion}? [y/N] `)).toLowerCase() === "y") { - await this.update(currentVersion, publishedVersion, downloadUrl) + private async fetchReleaseInfo(): Promise<{ releases: ReleaseInfo[], latestRelease: ReleaseInfo, canaryRelease: ReleaseInfo }> { + const fetchGithubAPI = async (url: string) => { + try { + const response = await fetch(url) + return await response.json() + } catch (e) { + errExit("fetch update info", e) } - } else { - outVerbose(`${currentVersion} is up to date.`) } + const latestRelease: ReleaseInfo = await fetchGithubAPI("https://api.github.com/repos/FilenCloudDienste/filen-cli/releases/latest") + const releases: ReleaseInfo[] = await fetchGithubAPI("https://api.github.com/repos/FilenCloudDienste/filen-cli/releases") + const canaryRelease = releases.sort((a, b) => semver.compare(a.tag_name, b.tag_name)).filter(release => !release.prerelease).reverse()[0]! + return { releases, latestRelease, canaryRelease } + } - // save update cache - (async () => { - if (!await exists(this.updateCacheDirectory)) { - await fs.promises.mkdir(this.updateCacheDirectory) + private getDownloadUrl(release: ReleaseInfo) { + const platformStr = (() => { + switch (process.platform) { + case "win32": return "win" + case "darwin": return "macos" + case "linux": return "linux" + default: errExit(`Error trying to update on unsupported platform ${process.platform}`) } - await fs.promises.writeFile(this.updateCacheFile, JSON.stringify({ lastCheckedUpdate: Date.now() })) })() + return release.assets.find(asset => asset.name.includes(platformStr) && asset.name.includes(process.arch))?.browser_download_url + } + + // install + + private async showChangelogs(releases: ReleaseInfo[], targetRelease: string) { + if (semver.lt(targetRelease, version)) return + if (await promptYesNo("Show changelogs?", true)) { + const passingReleases = releases.sort((a, b) => semver.compare(a.tag_name, b.tag_name)).filter(release => semver.gt(release.tag_name, version) && semver.lte(release.tag_name, targetRelease) && !release.prerelease) + const releaseBodies = passingReleases.map(release => `========== ${release.tag_name} ==========\n${release.body}\n${"=".repeat(22 + release.tag_name.length)}`) + out("\n\n" + releaseBodies.join("\n\n\n") + "\n\n") + } } - private async update(currentVersionName: string, publishedVersionName: string, downloadUrl: string) { + private async installVersion(currentVersionName: string, publishedVersionName: string, downloadUrl: string) { const selfApplicationFile = process.pkg === undefined ? __filename : process.argv[0]! const downloadedFile = path.join(path.dirname(selfApplicationFile), `filen_update_${publishedVersionName}`)