code-server/src/node/cli.ts

482 lines
14 KiB
TypeScript
Raw Normal View History

import { field, Level, logger } from "@coder/logger"
2020-05-10 12:19:32 +07:00
import * as fs from "fs-extra"
import yaml from "js-yaml"
import * as os from "os"
import * as path from "path"
import { Args as VsArgs } from "../../lib/vscode/src/vs/server/ipc"
import { AuthType } from "./http"
import { generatePassword, humanPath, paths } from "./util"
2020-02-07 07:26:07 +07:00
export class Optional<T> {
public constructor(public readonly value?: T) {}
}
2020-02-20 00:06:32 +07:00
export enum LogLevel {
Trace = "trace",
Debug = "debug",
Info = "info",
Warn = "warn",
Error = "error",
}
2020-02-07 07:26:07 +07:00
export class OptionalString extends Optional<string> {}
export interface Args extends VsArgs {
2020-05-10 12:19:32 +07:00
readonly config?: string
2020-02-07 07:26:07 +07:00
readonly auth?: AuthType
readonly password?: string
2020-02-07 07:26:07 +07:00
readonly cert?: OptionalString
readonly "cert-key"?: string
2020-02-19 01:24:12 +07:00
readonly "disable-telemetry"?: boolean
2020-02-07 07:26:07 +07:00
readonly help?: boolean
readonly host?: string
readonly json?: boolean
2020-02-20 00:06:32 +07:00
log?: LogLevel
2020-02-07 07:26:07 +07:00
readonly open?: boolean
readonly port?: number
readonly "bind-addr"?: string
2020-02-07 07:26:07 +07:00
readonly socket?: string
readonly version?: boolean
readonly force?: boolean
2020-02-19 01:24:12 +07:00
readonly "list-extensions"?: boolean
readonly "install-extension"?: string[]
readonly "show-versions"?: boolean
2020-02-19 01:24:12 +07:00
readonly "uninstall-extension"?: string[]
readonly "proxy-domain"?: string[]
2020-02-21 06:36:38 +07:00
readonly locale?: string
2020-02-07 07:26:07 +07:00
readonly _: string[]
readonly "reuse-window"?: boolean
readonly "new-window"?: boolean
2020-09-09 06:39:17 +07:00
2020-09-09 11:06:28 +07:00
readonly expose?: OptionalString
2020-02-07 07:26:07 +07:00
}
interface Option<T> {
type: T
/**
* Short flag for the option.
*/
short?: string
/**
* Whether the option is a path and should be resolved.
*/
path?: boolean
/**
* Description of the option. Leave blank to hide the option.
*/
description?: string
}
type OptionType<T> = T extends boolean
? "boolean"
: T extends OptionalString
? typeof OptionalString
2020-02-20 00:06:32 +07:00
: T extends LogLevel
? typeof LogLevel
2020-02-07 07:26:07 +07:00
: T extends AuthType
? typeof AuthType
: T extends number
? "number"
: T extends string
? "string"
: T extends string[]
? "string[]"
: "unknown"
type Options<T> = {
[P in keyof T]: Option<OptionType<T[P]>>
}
2020-02-07 07:26:07 +07:00
const options: Options<Required<Args>> = {
auth: { type: AuthType, description: "The type of authentication to use." },
password: {
type: "string",
description: "The password for password authentication (can only be passed in via $PASSWORD or the config file).",
},
2020-02-07 07:26:07 +07:00
cert: {
type: OptionalString,
path: true,
description: "Path to certificate. Generated if no path is provided.",
},
"cert-key": { type: "string", path: true, description: "Path to certificate key when using non-generated cert." },
2020-02-19 01:24:12 +07:00
"disable-telemetry": { type: "boolean", description: "Disable telemetry." },
2020-02-07 07:26:07 +07:00
help: { type: "boolean", short: "h", description: "Show this output." },
json: { type: "boolean" },
2020-02-20 00:06:32 +07:00
open: { type: "boolean", description: "Open in browser on startup. Does not work remotely." },
"bind-addr": {
type: "string",
description: "Address to bind to in host:port. You can also use $PORT to override the port.",
},
config: {
type: "string",
description: "Path to yaml config file. Every flag maps directly to a key in the config file.",
},
2020-05-10 12:19:32 +07:00
// These two have been deprecated by bindAddr.
host: { type: "string", description: "" },
port: { type: "number", description: "" },
2020-04-29 05:29:25 +07:00
socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." },
2020-02-07 07:26:07 +07:00
version: { type: "boolean", short: "v", description: "Display version information." },
_: { type: "string[]" },
"user-data-dir": { type: "string", path: true, description: "Path to the user data directory." },
"extensions-dir": { type: "string", path: true, description: "Path to the extensions directory." },
"builtin-extensions-dir": { type: "string", path: true },
"extra-extensions-dir": { type: "string[]", path: true },
"extra-builtin-extensions-dir": { type: "string[]", path: true },
"list-extensions": { type: "boolean", description: "List installed VS Code extensions." },
force: { type: "boolean", description: "Avoid prompts when installing VS Code extensions." },
"install-extension": {
type: "string[]",
2020-09-09 11:06:28 +07:00
description:
"Install or update a VS Code extension by id or vsix. The identifier of an extension is `${publisher}.${name}`.\n" +
"To install a specific version provide `@${version}`. For example: 'vscode.csharp@1.2.3'.",
},
"enable-proposed-api": {
type: "string[]",
description:
"Enable proposed API features for extensions. Can receive one or more extension IDs to enable individually.",
},
"uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
"show-versions": { type: "boolean", description: "Show VS Code extension versions." },
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
2020-02-07 07:26:07 +07:00
"new-window": {
type: "boolean",
short: "n",
description: "Force to open a new window. (use with open-in)",
},
"reuse-window": {
type: "boolean",
short: "r",
description: "Force to open a file or folder in an already opened window. (use with open-in)",
},
locale: { type: "string" },
2020-02-20 00:06:32 +07:00
log: { type: LogLevel },
2020-02-07 07:26:07 +07:00
verbose: { type: "boolean", short: "vvv", description: "Enable verbose logging." },
2020-09-09 06:39:17 +07:00
2020-09-09 11:06:28 +07:00
expose: {
2020-09-09 07:30:31 +07:00
type: OptionalString,
description: `
Securely expose code-server via Coder Cloud with the passed name. You'll get a URL like
https://myname.coder-cloud.com at which you can easily access your code-server instance.
Authorization is done via GitHub. Only the first code-server spawned with the current
2020-09-09 11:06:28 +07:00
configuration will be accessible.`,
2020-09-09 07:30:31 +07:00
},
2020-02-07 07:26:07 +07:00
}
export const optionDescriptions = (): string[] => {
const entries = Object.entries(options).filter(([, v]) => !!v.description)
const widths = entries.reduce(
(prev, [k, v]) => ({
long: k.length > prev.long ? k.length : prev.long,
short: v.short && v.short.length > prev.short ? v.short.length : prev.short,
}),
2020-02-15 07:46:00 +07:00
{ short: 0, long: 0 },
2020-02-07 07:26:07 +07:00
)
2020-09-09 11:06:28 +07:00
return entries.map(([k, v]) => {
const help = `${" ".repeat(widths.short - (v.short ? v.short.length : 0))}${v.short ? `-${v.short}` : " "} --${k} `
return (
help +
v.description
?.trim()
.split(/\n/)
.map((line, i) => {
line = line.trim()
if (i === 0) {
return " ".repeat(widths.long - k.length) + line
}
return " ".repeat(widths.long + widths.short + 6) + line
})
.join("\n")
)
})
2020-02-07 07:26:07 +07:00
}
export const parse = (
argv: string[],
opts?: {
configFile: string
},
): Args => {
const error = (msg: string): Error => {
if (opts?.configFile) {
msg = `error reading ${opts.configFile}: ${msg}`
}
return new Error(msg)
}
2020-02-07 07:26:07 +07:00
const args: Args = { _: [] }
let ended = false
for (let i = 0; i < argv.length; ++i) {
const arg = argv[i]
// -- signals the end of option parsing.
2020-08-05 03:08:45 +07:00
if (!ended && arg === "--") {
2020-02-07 07:26:07 +07:00
ended = true
continue
}
2020-02-07 07:26:07 +07:00
// Options start with a dash and require a value if non-boolean.
if (!ended && arg.startsWith("-")) {
let key: keyof Args | undefined
let value: string | undefined
2020-02-07 07:26:07 +07:00
if (arg.startsWith("--")) {
const split = arg.replace(/^--/, "").split("=", 2)
key = split[0] as keyof Args
value = split[1]
2020-02-07 07:26:07 +07:00
} else {
const short = arg.replace(/^-/, "")
const pair = Object.entries(options).find(([, v]) => v.short === short)
if (pair) {
key = pair[0] as keyof Args
}
}
if (!key || !options[key]) {
throw error(`Unknown option ${arg}`)
}
if (key === "password" && !opts?.configFile) {
throw new Error("--password can only be set in the config file or passed in via $PASSWORD")
2020-02-07 07:26:07 +07:00
}
const option = options[key]
if (option.type === "boolean") {
;(args[key] as boolean) = true
continue
}
// Might already have a value if it was the --long=value format.
if (typeof value === "undefined") {
// A value is only valid if it doesn't look like an option.
value = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : undefined
}
2020-02-07 07:26:07 +07:00
if (!value && option.type === OptionalString) {
;(args[key] as OptionalString) = new OptionalString(value)
continue
} else if (!value) {
throw error(`--${key} requires a value`)
}
2020-08-05 03:08:45 +07:00
if (option.type === OptionalString && value === "false") {
continue
2020-02-07 07:26:07 +07:00
}
if (option.path) {
value = path.resolve(value)
}
switch (option.type) {
case "string":
;(args[key] as string) = value
break
case "string[]":
if (!args[key]) {
;(args[key] as string[]) = []
}
;(args[key] as string[]).push(value)
break
case "number":
;(args[key] as number) = parseInt(value, 10)
if (isNaN(args[key] as number)) {
throw error(`--${key} must be a number`)
2020-02-07 07:26:07 +07:00
}
break
case OptionalString:
;(args[key] as OptionalString) = new OptionalString(value)
break
default: {
if (!Object.values(option.type).includes(value)) {
throw error(`--${key} valid values: [${Object.values(option.type).join(", ")}]`)
2020-02-07 07:26:07 +07:00
}
;(args[key] as string) = value
break
}
}
continue
}
// Everything else goes into _.
args._.push(arg)
}
2020-02-07 07:26:07 +07:00
logger.debug("parsed command line", field("args", args))
// --verbose takes priority over --log and --log takes priority over the
// environment variable.
if (args.verbose) {
args.log = LogLevel.Trace
} else if (
!args.log &&
process.env.LOG_LEVEL &&
Object.values(LogLevel).includes(process.env.LOG_LEVEL as LogLevel)
) {
2020-02-20 00:06:32 +07:00
args.log = process.env.LOG_LEVEL as LogLevel
}
2020-02-07 07:26:07 +07:00
// Sync --log, --verbose, the environment variable, and logger level.
if (args.log) {
process.env.LOG_LEVEL = args.log
}
2020-02-07 07:26:07 +07:00
switch (args.log) {
2020-02-20 00:06:32 +07:00
case LogLevel.Trace:
2020-02-07 07:26:07 +07:00
logger.level = Level.Trace
args.verbose = true
2020-02-07 07:26:07 +07:00
break
2020-02-20 00:06:32 +07:00
case LogLevel.Debug:
2020-02-07 07:26:07 +07:00
logger.level = Level.Debug
args.verbose = false
2020-02-07 07:26:07 +07:00
break
2020-02-20 00:06:32 +07:00
case LogLevel.Info:
2020-02-07 07:26:07 +07:00
logger.level = Level.Info
args.verbose = false
2020-02-07 07:26:07 +07:00
break
2020-02-20 00:06:32 +07:00
case LogLevel.Warn:
2020-02-07 07:26:07 +07:00
logger.level = Level.Warning
args.verbose = false
2020-02-07 07:26:07 +07:00
break
2020-02-20 00:06:32 +07:00
case LogLevel.Error:
2020-02-07 07:26:07 +07:00
logger.level = Level.Error
args.verbose = false
2020-02-07 07:26:07 +07:00
break
}
return args
}
export async function setDefaults(args: Args): Promise<Args> {
args = { ...args }
2020-02-07 07:26:07 +07:00
if (!args["user-data-dir"]) {
await copyOldMacOSDataDir()
args["user-data-dir"] = paths.data
2020-02-07 07:26:07 +07:00
}
if (!args["extensions-dir"]) {
args["extensions-dir"] = path.join(args["user-data-dir"], "extensions")
}
return args
}
2020-05-10 12:19:32 +07:00
async function defaultConfigFile(): Promise<string> {
return `bind-addr: 127.0.0.1:8080
auth: password
password: ${await generatePassword()}
cert: false
`
}
/**
* Reads the code-server yaml config file and returns it as Args.
*
* @param configPath Read the config from configPath instead of $CODE_SERVER_CONFIG or the default.
*/
export async function readConfigFile(configPath?: string): Promise<Args> {
if (!configPath) {
configPath = process.env.CODE_SERVER_CONFIG
if (!configPath) {
configPath = path.join(paths.config, "config.yaml")
}
}
2020-05-10 12:19:32 +07:00
if (!(await fs.pathExists(configPath))) {
await fs.outputFile(configPath, await defaultConfigFile())
logger.info(`Wrote default config file to ${humanPath(configPath)}`)
2020-05-10 12:19:32 +07:00
}
2020-08-28 03:04:37 +07:00
if (!process.env.CODE_SERVER_PARENT_PID && !process.env.VSCODE_IPC_HOOK_CLI) {
logger.info(`Using config file ${humanPath(configPath)}`)
}
2020-05-10 12:19:32 +07:00
const configFile = await fs.readFile(configPath)
const config = yaml.safeLoad(configFile.toString(), {
filename: configPath,
2020-05-10 12:19:32 +07:00
})
2020-08-27 01:21:37 +07:00
if (!config || typeof config === "string") {
throw new Error(`invalid config: ${config}`)
}
2020-05-10 12:19:32 +07:00
// We convert the config file into a set of flags.
// This is a temporary measure until we add a proper CLI library.
const configFileArgv = Object.entries(config).map(([optName, opt]) => {
if (opt === true) {
return `--${optName}`
}
return `--${optName}=${opt}`
})
const args = parse(configFileArgv, {
configFile: configPath,
})
return {
...args,
config: configPath,
}
}
2020-05-10 12:19:32 +07:00
function parseBindAddr(bindAddr: string): [string, number] {
const u = new URL(`http://${bindAddr}`)
2020-09-30 23:56:49 +07:00
// With the http scheme 80 will be dropped so assume it's 80 if missing. This
// means --bind-addr <addr> without a port will default to 80 as well and not
// the code-server default.
return [u.hostname, u.port ? parseInt(u.port, 10) : 80]
2020-05-10 12:19:32 +07:00
}
interface Addr {
host: string
port: number
}
function bindAddrFromArgs(addr: Addr, args: Args): Addr {
addr = { ...addr }
if (args["bind-addr"]) {
;[addr.host, addr.port] = parseBindAddr(args["bind-addr"])
2020-05-10 12:19:32 +07:00
}
if (args.host) {
addr.host = args.host
2020-05-10 12:19:32 +07:00
}
if (process.env.PORT) {
addr.port = parseInt(process.env.PORT, 10)
}
if (args.port !== undefined) {
addr.port = args.port
}
return addr
}
export function bindAddrFromAllSources(cliArgs: Args, configArgs: Args): [string, number] {
let addr: Addr = {
host: "localhost",
port: 8080,
}
addr = bindAddrFromArgs(addr, configArgs)
addr = bindAddrFromArgs(addr, cliArgs)
return [addr.host, addr.port]
2020-05-10 12:19:32 +07:00
}
async function copyOldMacOSDataDir(): Promise<void> {
if (os.platform() !== "darwin") {
return
}
if (await fs.pathExists(paths.data)) {
return
}
// If the old data directory exists, we copy it in.
const oldDataDir = path.join(os.homedir(), "Library/Application Support", "code-server")
if (await fs.pathExists(oldDataDir)) {
await fs.copy(oldDataDir, paths.data)
}
}