diff --git a/.ci/template.common.yml b/.ci/template.common.yml index 2d750c27b25b..76c98c639ab2 100644 --- a/.ci/template.common.yml +++ b/.ci/template.common.yml @@ -3,4 +3,4 @@ parameters: steps: - bash: deno${{ parameters.exe_suffix }} run --allow-run --allow-write --allow-read format.ts --check - - bash: deno${{ parameters.exe_suffix }} run --allow-run --allow-net --allow-write --allow-read --config=tsconfig.test.json test.ts \ No newline at end of file + - bash: deno${{ parameters.exe_suffix }} run --allow-run --allow-net --allow-write --allow-read --allow-env --config=tsconfig.test.json test.ts \ No newline at end of file diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 000000000000..309364fbcf5f --- /dev/null +++ b/installer/README.md @@ -0,0 +1,70 @@ +# deno_installer + +Install remote or local script as executables. + +```` +## Installation + +`installer` can be install using iteself: + +```sh +deno -A https://deno.land/std/installer/mod.ts deno_installer https://deno.land/std/installer/mod.ts -A +```` + +Installer uses `~/.deno/bin` to store installed scripts so make sure it's in `$PATH` + +``` +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc # change this to your shell +``` + +## Usage + +Install script + +```sh +$ deno_installer file_server https://deno.land/std/http/file_server.ts --allow-net --allow-read +> Downloading: https://deno.land/std/http/file_server.ts +> +> ✅ Successfully installed file_server. + +# local script +$ deno_installer file_server ./deno_std/http/file_server.ts --allow-net --allow-read +> Looking for: /dev/deno_std/http/file_server.ts +> +> ✅ Successfully installed file_server. +``` + +Use installed script: + +```sh +$ file_server +HTTP server listening on http://0.0.0.0:4500/ +``` + +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 +> +> Downloading: https://deno.land/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 https://deno.land/std/installer/mod.ts 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. +``` diff --git a/installer/mod.ts b/installer/mod.ts new file mode 100644 index 000000000000..aff01f361644 --- /dev/null +++ b/installer/mod.ts @@ -0,0 +1,270 @@ +#!/usr/bin/env deno --allow-all + +const { + args, + env, + readDirSync, + mkdirSync, + writeFile, + exit, + stdin, + stat, + readAll, + run, + remove +} = Deno; +import * as path from "../fs/path.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8"); + +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 ""; +} + +async function readCharacter(): Promise { + const byteArray = new Uint8Array(1024); + await stdin.read(byteArray); + const line = decoder.decode(byteArray); + return line[0]; +} + +async function yesNoPrompt(message: string): Promise { + console.log(`${message} [yN]`); + const input = await readCharacter(); + console.log(); + return input === "y" || input === "Y"; +} + +function createDirIfNotExists(path: string): void { + try { + readDirSync(path); + } catch (e) { + mkdirSync(path, true); + } +} + +function checkIfExistsInPath(path: string): boolean { + const { PATH } = env(); + + const paths = (PATH as string).split(":"); + + return paths.includes(path); +} + +function getInstallerDir(): string { + const { HOME } = env(); + + if (!HOME) { + throw new Error("$HOME is not defined."); + } + + return path.join(HOME, ".deno", "bin"); +} + +// TODO: fetch doesn't handle redirects yet - once it does this function +// can be removed +async function fetchWithRedirects( + url: string, + redirectLimit: number = 10 + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + // TODO: `Response` is not exposed in global so 'any' + const response = await fetch(url); + + if (response.status === 301 || response.status === 302) { + if (redirectLimit > 0) { + const redirectUrl = response.headers.get("location")!; + return await fetchWithRedirects(redirectUrl, redirectLimit - 1); + } + } + + return response; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function fetchModule(url: string): Promise { + const response = await fetchWithRedirects(url); + + if (response.status !== 200) { + // TODO: show more debug information like status and maybe body + throw new Error(`Failed to get remote script ${url}.`); + } + + const body = await readAll(response.body); + return decoder.decode(body); +} + +function showHelp(): void { + console.log(`deno installer + Install remote or local script as executables. + +USAGE: + deno https://deno.land/std/installer/mod.ts 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. + `); +} + +export async function install( + moduleName: string, + moduleUrl: string, + flags: string[] +): Promise { + const installerDir = getInstallerDir(); + createDirIfNotExists(installerDir); + + const FILE_PATH = path.join(installerDir, moduleName); + + let fileInfo; + try { + fileInfo = await stat(FILE_PATH); + } catch (e) { + // pass + } + + if (fileInfo) { + const msg = `⚠️ ${moduleName} is already installed, do you want to overwrite it?`; + if (!(await yesNoPrompt(msg))) { + return; + } + } + + // ensure script that is being installed exists + if (moduleUrl.startsWith("http")) { + // remote module + console.log(`Downloading: ${moduleUrl}\n`); + await fetchModule(moduleUrl); + } else { + // assume that it's local file + moduleUrl = path.resolve(moduleUrl); + console.log(`Looking for: ${moduleUrl}\n`); + await stat(moduleUrl); + } + + 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", + ...grantedPermissions.map(getFlagFromPermission), + moduleUrl, + ...scriptArgs, + "$@" + ]; + + // TODO: add windows Version + const template = `#/bin/sh\n${commands.join(" ")}`; + await writeFile(FILE_PATH, encoder.encode(template)); + + const makeExecutable = run({ args: ["chmod", "+x", FILE_PATH] }); + const { code } = await makeExecutable.status(); + makeExecutable.close(); + + if (code !== 0) { + throw new Error("Failed to make file executable"); + } + + console.log(`✅ Successfully installed ${moduleName}.`); + // TODO: add Windows version + if (!checkIfExistsInPath(installerDir)) { + console.log("\nℹ️ Add ~/.deno/bin to PATH"); + console.log( + " echo 'export PATH=\"$HOME/.deno/bin:$PATH\"' >> ~/.bashrc # change this to your shell" + ); + } +} + +export async function uninstall(moduleName: string): Promise { + const installerDir = getInstallerDir(); + const FILE_PATH = path.join(installerDir, moduleName); + + try { + await stat(FILE_PATH); + } catch (e) { + if (e instanceof Deno.DenoError && e.kind === Deno.ErrorKind.NotFound) { + throw new Error(`ℹ️ ${moduleName} not found`); + } + } + + await remove(FILE_PATH); + console.log(`ℹ️ Uninstalled ${moduleName}`); +} + +async function main(): Promise { + if (args.length < 3) { + return showHelp(); + } + + if (["-h", "--help"].includes(args[1])) { + return showHelp(); + } + + const moduleName = args[1]; + const moduleUrl = args[2]; + const flags = args.slice(3); + try { + await install(moduleName, moduleUrl, flags); + } catch (e) { + console.log(e); + exit(1); + } +} + +if (import.meta.main) { + main(); +} diff --git a/installer/test.ts b/installer/test.ts new file mode 100644 index 000000000000..1b1aa4200be2 --- /dev/null +++ b/installer/test.ts @@ -0,0 +1,122 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +const { readFile, run, stat, makeTempDir, remove, env } = Deno; + +import { test, runIfMain, TestFunction } from "../testing/mod.ts"; +import { assert, assertEquals, assertThrowsAsync } from "../testing/asserts.ts"; +import { BufReader, EOF } from "../io/bufio.ts"; +import { TextProtoReader } from "../textproto/mod.ts"; +import { install, uninstall } from "./mod.ts"; +import * as path from "../fs/path.ts"; + +let fileServer: Deno.Process; + +// copied from `http/file_server_test.ts` +async function startFileServer(): Promise { + fileServer = run({ + args: [ + "deno", + "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 !== EOF && s.includes("server listening")); +} + +function killFileServer(): void { + fileServer.close(); + fileServer.stdout!.close(); +} + +function installerTest(t: TestFunction): void { + const fn = async (): Promise => { + await startFileServer(); + const tempDir = await makeTempDir(); + const envVars = env(); + const originalHomeDir = envVars["HOME"]; + envVars["HOME"] = tempDir; + + try { + await t(); + } finally { + killFileServer(); + await remove(tempDir, { recursive: true }); + envVars["HOME"] = originalHomeDir; + } + }; + + test(fn); +} + +installerTest(async function installBasic(): Promise { + await install("file_srv", "http://localhost:4500/http/file_server.ts", []); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/file_srv"); + const fileInfo = await stat(filePath); + assert(fileInfo.isFile()); + + const fileBytes = await readFile(filePath); + const fileContents = new TextDecoder().decode(fileBytes); + assertEquals( + fileContents, + "#/bin/sh\ndeno http://localhost:4500/http/file_server.ts $@" + ); +}); + +installerTest(async function installWithFlags(): Promise { + await install("file_server", "http://localhost:4500/http/file_server.ts", [ + "--allow-net", + "--allow-read", + "--foobar" + ]); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/file_server"); + + const fileBytes = await readFile(filePath); + const fileContents = new TextDecoder().decode(fileBytes); + assertEquals( + fileContents, + "#/bin/sh\ndeno --allow-net --allow-read http://localhost:4500/http/file_server.ts --foobar $@" + ); +}); + +installerTest(async function uninstallBasic(): Promise { + await install("file_server", "http://localhost:4500/http/file_server.ts", []); + + const { HOME } = env(); + const filePath = path.resolve(HOME, ".deno/bin/file_server"); + + await uninstall("file_server"); + + let thrown = false; + try { + await stat(filePath); + } catch (e) { + thrown = true; + assert(e instanceof Deno.DenoError); + assertEquals(e.kind, Deno.ErrorKind.NotFound); + } + + assert(thrown); +}); + +installerTest(async function uninstallNonExistentModule(): Promise { + await assertThrowsAsync( + async (): Promise => { + await uninstall("file_server"); + }, + Error, + "file_server not found" + ); +}); + +runIfMain(import.meta); diff --git a/test.ts b/test.ts index 864f1b511298..c1381cb27609 100755 --- a/test.ts +++ b/test.ts @@ -11,6 +11,7 @@ import "./flags/test.ts"; import "./fs/test.ts"; import "./http/test.ts"; import "./io/test.ts"; +import "./installer/test.ts"; import "./log/test.ts"; import "./media_types/test.ts"; import "./mime/test.ts";