diff --git a/bin.mjs b/bin.mjs index 3e7594a..2ed837c 100755 --- a/bin.mjs +++ b/bin.mjs @@ -10,13 +10,74 @@ import arg from "arg"; import chalk from "chalk-template"; import os from "node:os"; import { createRequire } from "node:module"; -import { checkEnhancementStatus, setNotionPath } from "./scripts/electron.mjs"; +import { + getResourcePath, + getAppPath, + getBackupPath, + getCachePath, + checkEnhancementVersion, + setNotionPath, + unpackApp, + applyEnhancements, + takeBackup, + restoreBackup, + removeCache, +} from "./scripts/enhance-desktop-app.mjs"; +import { existsSync } from "node:fs"; -let __quiet = false; +let __quiet, __spinner; const nodeRequire = createRequire(import.meta.url), manifest = nodeRequire("./package.json"), - stdout = (...args) => __quiet || process.stdout.write(chalk(...args)), - stdoutRaw = (value) => __quiet || console.log(value); + print = (...args) => __quiet || process.stdout.write(chalk(...args)), + printObject = (value) => __quiet || console.dir(value, { depth: null }); + +const hideCursor = () => process.stdout.write("\x1b[?25l"), + showCursor = () => process.stdout.write("\x1b[?25h"), + stopSpinner = () => __spinner?.stop(), + startSpinner = () => { + // cleanup prev spinner + stopSpinner(); + let i = 0; + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + interval = setInterval(() => __spinner.step(), 80); + // prevent backspace removing existing stdout + print` `; + __spinner = { + step() { + i++; + // overwrite spinner with next frame + print`\b\b\b {bold.yellow ${frames[i % frames.length]}} `; + hideCursor(); + }, + stop() { + clearInterval(interval); + // overwrite spinner with arrow on completion + print`\b\b\b {bold.yellow →}\n`; + showCursor(); + }, + }; + }, + readStdin = () => { + return new Promise((res) => { + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (key) => { + process.stdin.pause(); + res(key); + }); + }); + }, + promptForValue = async (prompt, values) => { + let input; + // prevent line clear remove existing stdout + print`\n`; + do { + // clear line and continue prompting until valid input is received + print`\x1b[1A\r\x1b[K${prompt}`; + input = (await readStdin()).trim(); + } while (!values.includes(input)); + return input; + }; const commands = [ // ["command", "description"] @@ -25,7 +86,7 @@ const commands = [ ["check", "check the current state of the notion app"], ], options = [ - // ["comma, separated, aliases", [type, "description"]] + // ["alias, option=example", [type, "description"]] [ "--path=", [String, "provide notion installation location (defaults to auto-detected)"], @@ -34,9 +95,9 @@ const commands = [ ["--overwrite", [Boolean, ""]], ["-y, --yes", [Boolean, 'skip prompts; assume "yes" and run non-interactively']], ["-n, --no", [Boolean, 'skip prompts; assume "no" and run non-interactively']], - ["-q, --quiet", [Boolean, "hide all output"]], + ["-q, --quiet", [Boolean, 'skip prompts and hide all output; assume "no"']], ["-d, --debug", [Boolean, "show detailed error messages"]], - ["-j, --json", [Boolean, "display json output"]], + ["-j, --json", [Boolean, "display json output (where applicable)"]], ["-h, --help", [Boolean, "display usage information"]], ["-v, --version", [Boolean, "display version number"]], ], @@ -52,24 +113,54 @@ const commands = [ } } return args; + }, + compileOptsToJsonOutput = () => { + // the structure used to define options above + // is convenient and compact, but requires additional + // parsing to understand. this function processes + // options into a more explicitly defined structure + return options.map(([opt, [type, description]]) => { + const option = { + aliases: opt.split(", ").map((alias) => alias.split("=")[0]), + type, + description, + }, + example = opt + .split(", ") + .map((alias) => alias.split("=")[1]) + .find((value) => value); + if (example) option.example = example; + return option; + }); }; const args = arg(compileOptsToArgSpec(options)), printHelp = () => { - const cmdPad = Math.max(...commands.map(([cmd]) => cmd.length)), - optPad = Math.max(...options.map((opt) => opt[0].length)), - parseCmd = (cmd) => chalk` ${cmd[0].padEnd(cmdPad)} {grey :} ${cmd[1]}`, - parseOpt = (opt) => chalk` ${opt[0].padEnd(optPad)} {grey :} ${opt[1][1]}`; - stdout`{bold.rgb(245,245,245) ${manifest.name} v${manifest.version}} - {grey ${manifest.homepage}} - \n{bold.rgb(245,245,245) USAGE} - {yellow $} ${manifest.name} [options] - \n{bold.rgb(245,245,245) COMMANDS}\n${commands.map(parseCmd).join("\n")} - \n{bold.rgb(245,245,245) OPTIONS}\n${options.map(parseOpt).join("\n")}\n`; + const { name, version, homepage } = manifest, + usage = `${name} [options]`; + if (args["--json"]) { + printObject({ + name, + version, + homepage, + usage, + commands: Object.fromEntries(commands), + options: compileOptsToJsonOutput(), + }); + } else { + const cmdPad = Math.max(...commands.map(([cmd]) => cmd.length)), + optPad = Math.max(...options.map((opt) => opt[0].length)), + parseCmd = (cmd) => chalk` ${cmd[0].padEnd(cmdPad)} {grey :} ${cmd[1]}`, + parseOpt = (opt) => chalk` ${opt[0].padEnd(optPad)} {grey :} ${opt[1][1]}`; + print`{bold.whiteBright ${name} v${version}}\n{grey ${homepage}} + \n{bold.whiteBright USAGE}\n${name} [options] + \n{bold.whiteBright COMMANDS}\n${commands.map(parseCmd).join("\n")} + \n{bold.whiteBright OPTIONS}\n${options.map(parseOpt).join("\n")}\n`; + } }, printVersion = () => { if (args["--json"]) { - stdoutRaw({ + printObject({ [manifest.name]: manifest.version, node: process.version.slice(1), platform: process.platform, @@ -80,53 +171,105 @@ const args = arg(compileOptsToArgSpec(options)), const enhancerVersion = `${manifest.name}@v${manifest.version}`, nodeVersion = `node@${process.version}`, osVersion = `${process.platform}-${process.arch}/${os.release()}`; - stdout`${enhancerVersion} via ${nodeVersion} on ${osVersion}\n`; + print`${enhancerVersion} via ${nodeVersion} on ${osVersion}\n`; } }; + if (args["--quiet"]) __quiet = true; if (args["--help"]) [printHelp(), process.exit()]; if (args["--version"]) [printVersion(), process.exit()]; if (args["--path"]) setNotionPath(args["--path"]); +const defaultPromptValue = args["--yes"] + ? "y" + : args["--no"] || args["--quiet"] + ? "n" + : undefined; + +const notionNotFound = `notion installation not found (corrupted or nonexistent)`, + enhancerNotApplied = `notion-enhancer not applied (notion installation found)`, + onSuccess = chalk`{bold.whiteBright SUCCESS} {green ✔}`, + onFail = chalk`{bold.whiteBright FAILURE} {red ✘}`, + onCancel = chalk`{bold.whiteBright CANCELLED} {red ✘}`; + switch (args["_"][0]) { case "apply": { + print`{bold.whiteBright [NOTION-ENHANCER] APPLY} `; + // const res = await apply(notionPath, { + // overwritePrevious: promptRes, + // patchPrevious: opts.get("patch") ? true : false, + // takeBackup: opts.get("no-backup") ? false : true, + // }); + // if (res) { + // log`{bold.whiteBright SUCCESS} {green ✔}`; + // } else log`{bold.whiteBright CANCELLED} {red ✘}`; break; } case "remove": { + const appPath = getAppPath(), + backupPath = getBackupPath(), + cachePath = getCachePath(), + insertVersion = checkEnhancementVersion(); + print`{bold.whiteBright [NOTION-ENHANCER] REMOVE}\n`; + // restore notion to app.bak or app.asar.bak + if (!appPath) { + print` {grey * ${notionNotFound}}\n`; + } else if (insertVersion) { + print` {grey * notion installation found: notion-enhancer v${insertVersion} applied}\n`; + if (backupPath) { + print` {grey * backup found: restoring}`; + startSpinner(); + await restoreBackup(); + stopSpinner(); + } else { + print` {grey * backup not found: skipping}\n`; + print` {red * to remove the notion-enhancer from notion, uninstall notion and then install}\n`; + print` {red a vanilla version of the app from https://www.notion.so/desktop (mac, windows)}\n`; + print` {red or ${manifest.homepage}/getting-started/installation (linux)\n}`; + } + } else print` {grey * ${enhancerNotApplied}: skipping}\n`; + // optionally remove ~/.notion-enhancer + if (existsSync(cachePath)) { + print` {grey * cache found: ${cachePath}}\n`; + let deleteCache; + const prompt = chalk` {inverse > delete? [Y/n]:} `; + if (defaultPromptValue) { + deleteCache = defaultPromptValue; + print`${prompt}${defaultPromptValue}\n`; + } else deleteCache = await promptForValue(prompt, ["Y", "y", "N", "n"]); + if (["Y", "y"].includes(deleteCache)) { + print` {grey * cache found: removing}`; + startSpinner(); + await removeCache(); + stopSpinner(); + } else print` {grey * cache found: keeping}\n`; + } else print` {grey * cache not found: skipping}\n`; + // failure if backup could not be restored + print`${insertVersion && !backupPath ? onFail : onSuccess}\n`; break; } - // case "apply": { - // stdout`{bold.rgb(245,245,245) [NOTION-ENHANCER] APPLY}`; - // const res = await apply(notionPath, { - // overwritePrevious: promptRes, - // patchPrevious: opts.get("patch") ? true : false, - // takeBackup: opts.get("no-backup") ? false : true, - // }); - // if (res) { - // log`{bold.rgb(245,245,245) SUCCESS} {green ✔}`; - // } else log`{bold.rgb(245,245,245) CANCELLED} {red ✘}`; - // break; - // } - // case "remove": { - // log`{bold.rgb(245,245,245) [NOTION-ENHANCER] REMOVE}`; - // const res = await remove(notionPath, { delCache: promptRes }); - // if (res) { - // log`{bold.rgb(245,245,245) SUCCESS} {green ✔}`; - // } else log`{bold.rgb(245,245,245) CANCELLED} {red ✘}`; - // break; - // } case "check": - const status = checkEnhancementStatus(); - if (args["--json"]) [stdoutRaw(status), process.exit()]; - stdout`{bold.rgb(245,245,245) [NOTION-ENHANCER] CHECK:} `; - if (manifest.version === status.insertVersion) { - stdout`notion-enhancer v${manifest.version} applied.\n`; - } else if (status.insertVersion) { - stdout`notion-enhancer v${manifest.version} applied != v${status.insertVersion} cli.\n`; - } else if (status.appPath) { - stdout`notion-enhancer has not been applied (notion installation found).\n`; - } else { - stdout`notion installation not found (corrupted or nonexistent).\n`; + const appPath = getAppPath(), + backupPath = getBackupPath(), + cachePath = getCachePath(), + insertVersion = checkEnhancementVersion(); + if (args["--json"]) { + printObject({ + appPath, + backupPath, + cachePath, + doesCacheExist: existsSync(cachePath), + insertVersion, + }); + process.exit(); } + print`{bold.whiteBright [NOTION-ENHANCER] CHECK:} `; + if (manifest.version === insertVersion) { + print`notion-enhancer v${manifest.version} applied\n`; + } else if (insertVersion) { + print`notion-enhancer v${manifest.version} applied != v${insertVersion} cli\n`; + } else if (appPath) { + print`${enhancerNotApplied}.\n`; + } else print`${notionNotFound}.\n`; break; default: printHelp(); diff --git a/package.json b/package.json index cc64f3f..74e3398 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "node": ">=16.x.x" }, "dependencies": { + "@electron/asar": "^3.2.2", "arg": "^5.0.2", "chalk-template": "^0.4.0" }, diff --git a/scripts/electron.mjs b/scripts/enhance-desktop-app.mjs similarity index 53% rename from scripts/electron.mjs rename to scripts/enhance-desktop-app.mjs index 48133db..d87c072 100644 --- a/scripts/electron.mjs +++ b/scripts/enhance-desktop-app.mjs @@ -4,7 +4,7 @@ * (https://notion-enhancer.github.io/) under the MIT license */ -import chalk from "chalk-template"; +import asar from "@electron/asar"; import os from "node:os"; import { promises as fsp, existsSync } from "node:fs"; import { resolve } from "node:path"; @@ -43,11 +43,11 @@ const nodeRequire = createRequire(import.meta.url), const setNotionPath = (path) => { // sets notion resource path to user provided value - // e.g. from cli + // e.g. with the --path cli option __notionResources = path; }, - getNotionResources = () => { - if (__notionResources) return __notionResources; + getResourcePath = (path) => { + if (__notionResources) return resolve(`${__notionResources}/${path}`); polyfillWslEnv("LOCALAPPDATA"); polyfillWslEnv("PROGRAMW6432"); const potentialPaths = [ @@ -63,48 +63,75 @@ const setNotionPath = (path) => { if (!targetPlatforms.includes(platform)) continue; if (!existsSync(testPath)) continue; __notionResources = testPath; - return __notionResources; + return resolve(`${__notionResources}/${path}`); } }, - getEnhancerCache = () => { + // prefer unpacked if both exist + getAppPath = () => ["app", "app.asar"].map(getResourcePath).find(existsSync), + getBackupPath = () => ["app.bak", "app.asar.bak"].map(getResourcePath).find(existsSync), + getCachePath = () => { if (__enhancerCache) return __enhancerCache; const home = platform === "wsl" ? polyfillWslEnv("HOMEPATH") : os.homedir(); __enhancerCache = resolve(`${home}/.notion-enhancer`); return __enhancerCache; + }, + checkEnhancementVersion = () => { + const insertPath = getResourcePath("app/node_modules/notion-enhancer"); + if (!existsSync(insertPath)) return undefined; + const insertManifest = getResourcePath("app/node_modules/notion-enhancer/package.json"), + insertVersion = nodeRequire(insertManifest).version; + return insertVersion; }; -const checkEnhancementStatus = () => { - const resourcePath = (path) => resolve(`${getNotionResources()}/${path}`), - doesResourceExist = (path) => existsSync(resourcePath(path)); - - const isAppUnpacked = doesResourceExist("app"), - isAppPacked = doesResourceExist("app.asar"), - isBackupUnpacked = doesResourceExist("app.bak"), - isBackupPacked = doesResourceExist("app.asar.bak"), - isEnhancerInserted = doesResourceExist("app/node_module/notion-enhancer"), - enhancerInsertManifest = isEnhancerInserted - ? resourcePath("app/node_module/notion-enhancer/package.json") - : undefined; - - // prefer unpacked if both exist: extraction is slow - return { - appPath: isAppUnpacked - ? resourcePath("app") - : isAppPacked - ? resourcePath("app.asar") - : undefined, - backupPath: isBackupUnpacked - ? resourcePath("app.bak") - : isBackupPacked - ? resourcePath("app.asar.bak") - : undefined, - cachePath: existsSync(getEnhancerCache()) // - ? getEnhancerCache() - : undefined, - insertVersion: isEnhancerInserted - ? nodeRequire(enhancerInsertManifest).version - : undefined, +const unpackApp = () => { + const appPath = getAppPath(); + if (!appPath || !appPath.endsWith("asar")) return false; + asar.extractAll(appPath, appPath.replace(/\.asar$/, "")); + return true; + }, + applyEnhancements = () => { + const appPath = getAppPath(); + if (!appPath || appPath.endsWith("asar")) return false; + // ... + return true; + }, + takeBackup = async () => { + const appPath = getAppPath(); + if (!appPath) return false; + const backupPath = getBackupPath(); + if (backupPath) await fsp.rm(backupPath, { recursive: true }); + const destPath = `${appPath}.bak`; + if (!appPath.endsWith(".asar")) { + await fsp.cp(appPath, destPath, { recursive: true }); + } else await fsp.rename(appPath, destPath); + return true; + }, + restoreBackup = async () => { + const backupPath = getBackupPath(); + if (!backupPath) return false; + const destPath = backupPath.replace(/\.bak$/, ""); + if (existsSync(destPath)) await fsp.rm(destPath, { recursive: true }); + await fsp.rename(backupPath, destPath); + const appPath = getAppPath(); + if (destPath !== appPath) await fsp.rm(appPath, { recursive: true }); + return true; + }, + removeCache = async () => { + if (!existsSync(getCachePath())) return; + await fsp.rm(getCachePath()); + return true; }; + +export { + getResourcePath, + getAppPath, + getBackupPath, + getCachePath, + checkEnhancementVersion, + setNotionPath, + unpackApp, + applyEnhancements, + takeBackup, + restoreBackup, + removeCache, }; - -export { checkEnhancementStatus, setNotionPath };