diff options
Diffstat (limited to 'std/installer')
-rw-r--r-- | std/installer/README.md | 88 | ||||
-rw-r--r-- | std/installer/mod.ts | 303 | ||||
-rw-r--r-- | std/installer/test.ts | 407 | ||||
-rw-r--r-- | std/installer/testdata/args.ts | 9 | ||||
-rw-r--r-- | std/installer/testdata/echo.ts | 6 |
5 files changed, 813 insertions, 0 deletions
diff --git a/std/installer/README.md b/std/installer/README.md new file mode 100644 index 000000000..b3bd18563 --- /dev/null +++ b/std/installer/README.md @@ -0,0 +1,88 @@ +# deno_installer + +Install remote or local script as executables. + +## Installation + +`installer` can be installed using itself: + +```sh +deno -A https://deno.land/std/installer/mod.ts deno_installer https://deno.land/std/installer/mod.ts -A +``` + +## Usage + +Install script + +```sh +# remote script +$ deno_installer file_server https://deno.land/std/http/file_server.ts --allow-net --allow-read +> [1/1] Compiling https://deno.land/std/http/file_server.ts +> +> ✅ Successfully installed file_server. +> ~/.deno/bin/file_server + +# local script +$ deno_installer file_server ./deno_std/http/file_server.ts --allow-net --allow-read +> [1/1] Compiling file:///dev/deno_std/http/file_server.ts +> +> ✅ Successfully installed file_server. +> ~/.deno/bin/file_server +``` + +Run installed script: + +```sh +$ file_server +HTTP server listening on http://0.0.0.0:4500/ +``` + +## Custom installation directory + +By default installer uses `~/.deno/bin` to store installed scripts so make sure it's in your `$PATH`. + +``` +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc # change this to your shell +``` + +If you prefer to change installation directory use `-d` or `--dir` flag. + +``` +$ deno_installer --dir /usr/local/bin file_server ./deno_std/http/file_server.ts --allow-net --allow-read +> [1/1] Compiling file:///dev/deno_std/http/file_server.ts +> +> ✅ Successfully installed file_server. +> /usr/local/bin/file_server +``` + +## Update installed script + +```sh +$ deno_installer file_server https://deno.land/std/http/file_server.ts --allow-net --allow-read +> ⚠️ file_server is already installed, do you want to overwrite it? [yN] +> y +> +> [1/1] Compiling file:///dev/deno_std/http/file_server.ts +> +> ✅ Successfully installed file_server. +``` + +Show help + +```sh +$ deno_installer --help +> deno installer + Install remote or local script as executables. + +USAGE: + deno -A https://deno.land/std/installer/mod.ts [OPTIONS] EXE_NAME SCRIPT_URL [FLAGS...] + +ARGS: + EXE_NAME Name for executable + SCRIPT_URL Local or remote URL of script to install + [FLAGS...] List of flags for script, both Deno permission and script specific + flag can be used. + +OPTIONS: + -d, --dir <PATH> Installation directory path (defaults to ~/.deno/bin) +``` diff --git a/std/installer/mod.ts b/std/installer/mod.ts new file mode 100644 index 000000000..ef94e2e4e --- /dev/null +++ b/std/installer/mod.ts @@ -0,0 +1,303 @@ +#!/usr/bin/env -S deno --allow-all +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +const { env, stdin, args, exit, writeFile, chmod, run } = Deno; +import { parse } from "../flags/mod.ts"; +import * as path from "../fs/path.ts"; +import { exists } from "../fs/exists.ts"; +import { ensureDir } from "../fs/ensure_dir.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8"); +// Regular expression to test disk driver letter. eg "C:\\User\username\path\to" +const driverLetterReg = /^[c-z]:/i; +const isWindows = Deno.build.os === "win"; + +function showHelp(): void { + console.log(`deno installer + Install remote or local script as executables. + +USAGE: + deno -A https://deno.land/std/installer/mod.ts [OPTIONS] EXE_NAME SCRIPT_URL [FLAGS...] + +ARGS: + EXE_NAME Name for executable + SCRIPT_URL Local or remote URL of script to install + [FLAGS...] List of flags for script, both Deno permission and script specific + flag can be used. + +OPTIONS: + -d, --dir <PATH> Installation directory path (defaults to ~/.deno/bin) +`); +} + +enum Permission { + Read, + Write, + Net, + Env, + Run, + All +} + +function getPermissionFromFlag(flag: string): Permission | undefined { + switch (flag) { + case "--allow-read": + return Permission.Read; + case "--allow-write": + return Permission.Write; + case "--allow-net": + return Permission.Net; + case "--allow-env": + return Permission.Env; + case "--allow-run": + return Permission.Run; + case "--allow-all": + return Permission.All; + case "-A": + return Permission.All; + } +} + +function getFlagFromPermission(perm: Permission): string { + switch (perm) { + case Permission.Read: + return "--allow-read"; + case Permission.Write: + return "--allow-write"; + case Permission.Net: + return "--allow-net"; + case Permission.Env: + return "--allow-env"; + case Permission.Run: + return "--allow-run"; + case Permission.All: + return "--allow-all"; + } + return ""; +} + +function getInstallerDir(): string { + // In Windows's Powershell $HOME environmental variable maybe null + // if so use $HOMEPATH instead. + const { HOME, HOMEPATH } = env(); + + const HOME_PATH = HOME || HOMEPATH; + + if (!HOME_PATH) { + throw new Error("$HOME is not defined."); + } + + return path.join(HOME_PATH, ".deno", "bin"); +} + +async function readCharacter(): Promise<string> { + const byteArray = new Uint8Array(1024); + await stdin.read(byteArray); + const line = decoder.decode(byteArray); + return line[0]; +} + +async function yesNoPrompt(message: string): Promise<boolean> { + console.log(`${message} [yN]`); + const input = await readCharacter(); + console.log(); + return input === "y" || input === "Y"; +} + +function checkIfExistsInPath(filePath: string): boolean { + // In Windows's Powershell $PATH not exist, so use $Path instead. + // $HOMEDRIVE is only used on Windows. + const { PATH, Path, HOMEDRIVE } = env(); + + const envPath = (PATH as string) || (Path as string) || ""; + + const paths = envPath.split(isWindows ? ";" : ":"); + + let fileAbsolutePath = filePath; + + for (const p of paths) { + const pathInEnv = path.normalize(p); + // On Windows paths from env contain drive letter. + // (eg. C:\Users\username\.deno\bin) + // But in the path of Deno, there is no drive letter. + // (eg \Users\username\.deno\bin) + if (isWindows) { + if (driverLetterReg.test(pathInEnv)) { + fileAbsolutePath = HOMEDRIVE + "\\" + fileAbsolutePath; + } + } + if (pathInEnv === fileAbsolutePath) { + return true; + } + fileAbsolutePath = filePath; + } + + return false; +} + +export function isRemoteUrl(url: string): boolean { + return /^https?:\/\//.test(url); +} + +function validateModuleName(moduleName: string): boolean { + if (/^[a-z][\w-]*$/i.test(moduleName)) { + return true; + } else { + throw new Error("Invalid module name: " + moduleName); + } +} + +async function generateExecutable( + filePath: string, + commands: string[] +): Promise<void> { + commands = commands.map((v): string => JSON.stringify(v)); + // On Windows if user is using Powershell .cmd extension is need to run the + // installed module. + // Generate batch script to satisfy that. + const templateHeader = + "This executable is generated by Deno. Please don't modify it unless you " + + "know what it means."; + if (isWindows) { + const template = `% ${templateHeader} % +@IF EXIST "%~dp0\deno.exe" ( + "%~dp0\deno.exe" ${commands.slice(1).join(" ")} %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.TS;=;% + ${commands.join(" ")} %* +) +`; + const cmdFile = filePath + ".cmd"; + await writeFile(cmdFile, encoder.encode(template)); + await chmod(cmdFile, 0o755); + } + + // generate Shell script + const template = `#!/bin/sh +# ${templateHeader} +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +if [ -x "$basedir/deno" ]; then + "$basedir/deno" ${commands.slice(1).join(" ")} "$@" + ret=$? +else + ${commands.join(" ")} "$@" + ret=$? +fi +exit $ret +`; + await writeFile(filePath, encoder.encode(template)); + await chmod(filePath, 0o755); +} + +export async function install( + moduleName: string, + moduleUrl: string, + flags: string[], + installationDir?: string +): Promise<void> { + if (!installationDir) { + installationDir = getInstallerDir(); + } + await ensureDir(installationDir); + + // if install local module + if (!isRemoteUrl(moduleUrl)) { + moduleUrl = path.resolve(moduleUrl); + } + + validateModuleName(moduleName); + const filePath = path.join(installationDir, moduleName); + + if (await exists(filePath)) { + const msg = + "⚠️ " + + moduleName + + " is already installed" + + ", do you want to overwrite it?"; + if (!(await yesNoPrompt(msg))) { + return; + } + } + + // ensure script that is being installed exists + const ps = run({ + args: [Deno.execPath(), "fetch", "--reload", moduleUrl], + stdout: "inherit", + stderr: "inherit" + }); + + const { code } = await ps.status(); + + if (code !== 0) { + throw new Error("Failed to fetch module."); + } + + const grantedPermissions: Permission[] = []; + const scriptArgs: string[] = []; + + for (const flag of flags) { + const permission = getPermissionFromFlag(flag); + if (permission === undefined) { + scriptArgs.push(flag); + } else { + grantedPermissions.push(permission); + } + } + + const commands = [ + "deno", + "run", + ...grantedPermissions.map(getFlagFromPermission), + moduleUrl, + ...scriptArgs + ]; + + await generateExecutable(filePath, commands); + + console.log(`✅ Successfully installed ${moduleName}`); + console.log(filePath); + + if (!checkIfExistsInPath(installationDir)) { + console.log(`\nℹ️ Add ${installationDir} to PATH`); + console.log( + " echo 'export PATH=\"" + + installationDir + + ":$PATH\"' >> ~/.bashrc # change" + + " this to your shell" + ); + } +} + +async function main(): Promise<void> { + const parsedArgs = parse(args.slice(1), { stopEarly: true }); + + if (parsedArgs.h || parsedArgs.help) { + return showHelp(); + } + + if (parsedArgs._.length < 2) { + return showHelp(); + } + + const moduleName = parsedArgs._[0]; + const moduleUrl = parsedArgs._[1]; + const flags = parsedArgs._.slice(2); + const installationDir = parsedArgs.d || parsedArgs.dir; + + try { + await install(moduleName, moduleUrl, flags, installationDir); + } catch (e) { + console.log(e); + exit(1); + } +} + +if (import.meta.main) { + main(); +} diff --git a/std/installer/test.ts b/std/installer/test.ts new file mode 100644 index 000000000..bfe9bf652 --- /dev/null +++ b/std/installer/test.ts @@ -0,0 +1,407 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +const { run, stat, makeTempDir, remove, env, readAll } = Deno; + +import { test, runIfMain, TestFunction } from "../testing/mod.ts"; +import { assert, assertEquals } from "../testing/asserts.ts"; +import { BufReader } from "../io/bufio.ts"; +import { TextProtoReader } from "../textproto/mod.ts"; +import * as path from "../fs/path.ts"; +import * as fs from "../fs/mod.ts"; +import { install, isRemoteUrl } from "./mod.ts"; + +let fileServer: Deno.Process; +const isWindows = Deno.build.os === "win"; + +// copied from `http/file_server_test.ts` +async function startFileServer(): Promise<void> { + fileServer = run({ + args: [ + Deno.execPath(), + "run", + "--allow-read", + "--allow-net", + "http/file_server.ts", + ".", + "--cors" + ], + stdout: "piped" + }); + // Once fileServer is ready it will write to its stdout. + const r = new TextProtoReader(new BufReader(fileServer.stdout!)); + const s = await r.readLine(); + assert(s !== Deno.EOF && s.includes("server listening")); +} + +function killFileServer(): void { + fileServer.close(); + fileServer.stdout!.close(); +} + +function installerTest(t: TestFunction, useOriginHomeDir = false): void { + const fn = async (): Promise<void> => { + await startFileServer(); + const tempDir = await makeTempDir(); + const envVars = env(); + const originalHomeDir = envVars["HOME"]; + if (!useOriginHomeDir) { + envVars["HOME"] = tempDir; + } + + try { + await t(); + } finally { + killFileServer(); + await remove(tempDir, { recursive: true }); + envVars["HOME"] = originalHomeDir; + } + }; + + test(fn); +} + +installerTest(async function installBasic(): Promise<void> { + await install( + "echo_test", + "http://localhost:4500/installer/testdata/echo.ts", + [] + ); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/echo_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + if (isWindows) { + assertEquals( + await fs.readFileStr(filePath + ".cmd"), + /* eslint-disable max-len */ + `% This executable is generated by Deno. Please don't modify it unless you know what it means. % +@IF EXIST "%~dp0\deno.exe" ( + "%~dp0\deno.exe" "run" "http://localhost:4500/installer/testdata/echo.ts" %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.TS;=;% + "deno" "run" "http://localhost:4500/installer/testdata/echo.ts" %* +) +` + /* eslint-enable max-len */ + ); + } + + assertEquals( + await fs.readFileStr(filePath), + /* eslint-disable max-len */ + `#!/bin/sh +# This executable is generated by Deno. Please don't modify it unless you know what it means. +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +if [ -x "$basedir/deno" ]; then + "$basedir/deno" "run" "http://localhost:4500/installer/testdata/echo.ts" "$@" + ret=$? +else + "deno" "run" "http://localhost:4500/installer/testdata/echo.ts" "$@" + ret=$? +fi +exit $ret +` + /* eslint-enable max-len */ + ); +}); + +installerTest(async function installCustomDir(): Promise<void> { + const tempDir = await makeTempDir(); + + await install( + "echo_test", + "http://localhost:4500/installer/testdata/echo.ts", + [], + tempDir + ); + + const filePath = path.resolve(tempDir, "echo_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + if (isWindows) { + assertEquals( + await fs.readFileStr(filePath + ".cmd"), + /* eslint-disable max-len */ + `% This executable is generated by Deno. Please don't modify it unless you know what it means. % +@IF EXIST "%~dp0\deno.exe" ( + "%~dp0\deno.exe" "run" "http://localhost:4500/installer/testdata/echo.ts" %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.TS;=;% + "deno" "run" "http://localhost:4500/installer/testdata/echo.ts" %* +) +` + /* eslint-enable max-len */ + ); + } + + assertEquals( + await fs.readFileStr(filePath), + /* eslint-disable max-len */ + `#!/bin/sh +# This executable is generated by Deno. Please don't modify it unless you know what it means. +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +if [ -x "$basedir/deno" ]; then + "$basedir/deno" "run" "http://localhost:4500/installer/testdata/echo.ts" "$@" + ret=$? +else + "deno" "run" "http://localhost:4500/installer/testdata/echo.ts" "$@" + ret=$? +fi +exit $ret +` + /* eslint-enable max-len */ + ); +}); + +installerTest(async function installLocalModule(): Promise<void> { + let localModule = path.join(Deno.cwd(), "installer", "testdata", "echo.ts"); + await install("echo_test", localModule, []); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/echo_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + if (isWindows) { + localModule = localModule.replace(/\\/g, "\\\\"); + } + + if (isWindows) { + assertEquals( + await fs.readFileStr(filePath + ".cmd"), + /* eslint-disable max-len */ + `% This executable is generated by Deno. Please don't modify it unless you know what it means. % +@IF EXIST "%~dp0\deno.exe" ( + "%~dp0\deno.exe" "run" "${localModule}" %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.TS;=;% + "deno" "run" "${localModule}" %* +) +` + /* eslint-enable max-len */ + ); + } + + assertEquals( + await fs.readFileStr(filePath), + /* eslint-disable max-len */ + `#!/bin/sh +# This executable is generated by Deno. Please don't modify it unless you know what it means. +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +if [ -x "$basedir/deno" ]; then + "$basedir/deno" "run" "${localModule}" "$@" + ret=$? +else + "deno" "run" "${localModule}" "$@" + ret=$? +fi +exit $ret +` + /* eslint-enable max-len */ + ); +}); + +installerTest(async function installWithFlags(): Promise<void> { + await install( + "echo_test", + "http://localhost:4500/installer/testdata/echo.ts", + ["--allow-net", "--allow-read", "--foobar"] + ); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/echo_test"); + + if (isWindows) { + assertEquals( + await fs.readFileStr(filePath + ".cmd"), + /* eslint-disable max-len */ + `% This executable is generated by Deno. Please don't modify it unless you know what it means. % +@IF EXIST "%~dp0\deno.exe" ( + "%~dp0\deno.exe" "run" "--allow-net" "--allow-read" "http://localhost:4500/installer/testdata/echo.ts" "--foobar" %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.TS;=;% + "deno" "run" "--allow-net" "--allow-read" "http://localhost:4500/installer/testdata/echo.ts" "--foobar" %* +) +` + /* eslint-enable max-len */ + ); + } + + assertEquals( + await fs.readFileStr(filePath), + /* eslint-disable max-len */ + `#!/bin/sh +# This executable is generated by Deno. Please don't modify it unless you know what it means. +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*) basedir=\`cygpath -w "$basedir"\`;; +esac + +if [ -x "$basedir/deno" ]; then + "$basedir/deno" "run" "--allow-net" "--allow-read" "http://localhost:4500/installer/testdata/echo.ts" "--foobar" "$@" + ret=$? +else + "deno" "run" "--allow-net" "--allow-read" "http://localhost:4500/installer/testdata/echo.ts" "--foobar" "$@" + ret=$? +fi +exit $ret +` + /* eslint-enable max-len */ + ); +}); + +installerTest(async function installLocalModuleAndRun(): Promise<void> { + const localModule = path.join(Deno.cwd(), "installer", "testdata", "echo.ts"); + await install("echo_test", localModule, ["hello"]); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/echo_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + const ps = run({ + args: ["echo_test" + (isWindows ? ".cmd" : ""), "foo"], + stdout: "piped" + }); + + if (!ps.stdout) { + assert(!!ps.stdout, "There should have stdout."); + return; + } + + let thrown = false; + + try { + const b = await readAll(ps.stdout); + + const s = new TextDecoder("utf-8").decode(b); + + assertEquals(s, "hello, foo"); + } catch (err) { + console.error(err); + thrown = true; + } finally { + await remove(filePath); + ps.close(); + } + + assert(!thrown, "It should not throw an error"); +}, true); // set true to install module in your real $HOME dir. + +installerTest(async function installAndMakesureItCanRun(): Promise<void> { + await install( + "echo_test", + "http://localhost:4500/installer/testdata/echo.ts", + ["hello"] + ); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/echo_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + const ps = run({ + args: ["echo_test" + (isWindows ? ".cmd" : ""), "foo"], + stdout: "piped" + }); + + if (!ps.stdout) { + assert(!!ps.stdout, "There should have stdout."); + return; + } + + let thrown = false; + + try { + const b = await readAll(ps.stdout); + + const s = new TextDecoder("utf-8").decode(b); + + assertEquals(s, "hello, foo"); + } catch (err) { + console.error(err); + thrown = true; + } finally { + await remove(filePath); + ps.close(); + } + + assert(!thrown, "It should not throw an error"); +}, true); // set true to install module in your real $HOME dir. + +installerTest(async function installAndMakesureArgsRight(): Promise<void> { + await install( + "args_test", + "http://localhost:4500/installer/testdata/args.ts", + ["arg1", "--flag1"] + ); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/args_test"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + const ps = run({ + args: ["args_test" + (isWindows ? ".cmd" : ""), "arg2", "--flag2"], + stdout: "piped" + }); + + if (!ps.stdout) { + assert(!!ps.stdout, "There should have stdout."); + return; + } + + let thrown = false; + + try { + const b = await readAll(ps.stdout); + + const s = new TextDecoder("utf-8").decode(b); + + const obj = JSON.parse(s); + + assertEquals(obj[0], "arg1"); + assertEquals(obj[1], "--flag1"); + assertEquals(obj[2], "arg2"); + assertEquals(obj[3], "--flag2"); + } catch (err) { + console.error(err); + thrown = true; + } finally { + await remove(filePath); + ps.close(); + } + + assert(!thrown, "It should not throw an error"); +}, true); // set true to install module in your real $HOME dir. + +test(function testIsRemoteUrl(): void { + assert(isRemoteUrl("https://deno.land/std/http/file_server.ts")); + assert(isRemoteUrl("http://deno.land/std/http/file_server.ts")); + assert(!isRemoteUrl("file:///dev/deno_std/http/file_server.ts")); + assert(!isRemoteUrl("./dev/deno_std/http/file_server.ts")); +}); + +runIfMain(import.meta); diff --git a/std/installer/testdata/args.ts b/std/installer/testdata/args.ts new file mode 100644 index 000000000..484cab5ab --- /dev/null +++ b/std/installer/testdata/args.ts @@ -0,0 +1,9 @@ +function args(args: string[]) { + const map = {}; + for (let i = 0; i < args.length; i++) { + map[i] = args[i]; + } + Deno.stdout.write(new TextEncoder().encode(JSON.stringify(map))); +} + +args(Deno.args.slice(1)); diff --git a/std/installer/testdata/echo.ts b/std/installer/testdata/echo.ts new file mode 100644 index 000000000..62ddd6d05 --- /dev/null +++ b/std/installer/testdata/echo.ts @@ -0,0 +1,6 @@ +function echo(args: string[]) { + const msg = args.join(", "); + Deno.stdout.write(new TextEncoder().encode(msg)); +} + +echo(Deno.args.slice(1)); |