c08e3bb06d
See https://github.com/cdr/code-server/issues/2222#issuecomment-765235938 Makes way more sense.
665 lines
19 KiB
TypeScript
665 lines
19 KiB
TypeScript
import { field, Level, logger } from "@coder/logger"
|
|
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 { canConnect, generateCertificate, generatePassword, humanPath, paths } from "./util"
|
|
|
|
export enum AuthType {
|
|
Password = "password",
|
|
None = "none",
|
|
}
|
|
|
|
export class Optional<T> {
|
|
public constructor(public readonly value?: T) {}
|
|
}
|
|
|
|
export enum LogLevel {
|
|
Trace = "trace",
|
|
Debug = "debug",
|
|
Info = "info",
|
|
Warn = "warn",
|
|
Error = "error",
|
|
}
|
|
|
|
export class OptionalString extends Optional<string> {}
|
|
|
|
export interface Args extends VsArgs {
|
|
config?: string
|
|
auth?: AuthType
|
|
password?: string
|
|
"hashed-password"?: string
|
|
cert?: OptionalString
|
|
"cert-host"?: string
|
|
"cert-key"?: string
|
|
"disable-telemetry"?: boolean
|
|
"disable-update-check"?: boolean
|
|
help?: boolean
|
|
host?: string
|
|
json?: boolean
|
|
log?: LogLevel
|
|
open?: boolean
|
|
port?: number
|
|
"bind-addr"?: string
|
|
socket?: string
|
|
version?: boolean
|
|
force?: boolean
|
|
"list-extensions"?: boolean
|
|
"install-extension"?: string[]
|
|
"show-versions"?: boolean
|
|
"uninstall-extension"?: string[]
|
|
"proxy-domain"?: string[]
|
|
locale?: string
|
|
_: string[]
|
|
"reuse-window"?: boolean
|
|
"new-window"?: boolean
|
|
|
|
link?: OptionalString
|
|
home?: string
|
|
}
|
|
|
|
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
|
|
|
|
/**
|
|
* If marked as beta, the option is marked as beta in help.
|
|
*/
|
|
beta?: boolean
|
|
}
|
|
|
|
type OptionType<T> = T extends boolean
|
|
? "boolean"
|
|
: T extends OptionalString
|
|
? typeof OptionalString
|
|
: T extends LogLevel
|
|
? typeof LogLevel
|
|
: 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]>>
|
|
}
|
|
|
|
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).",
|
|
},
|
|
"hashed-password": {
|
|
type: "string",
|
|
description:
|
|
"The password hashed with SHA-256 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" +
|
|
"Takes precedence over 'password'.",
|
|
},
|
|
cert: {
|
|
type: OptionalString,
|
|
path: true,
|
|
description: "Path to certificate. A self signed certificate is generated if none is provided.",
|
|
},
|
|
"cert-host": {
|
|
type: "string",
|
|
description: "Hostname to use when generating a self signed certificate.",
|
|
},
|
|
"cert-key": { type: "string", path: true, description: "Path to certificate key when using non-generated cert." },
|
|
"disable-telemetry": { type: "boolean", description: "Disable telemetry." },
|
|
"disable-update-check": {
|
|
type: "boolean",
|
|
description:
|
|
"Disable update check. Without this flag, code-server checks every 6 hours against the latest github release and \n" +
|
|
"then notifies you once every week that a new release is available.",
|
|
},
|
|
help: { type: "boolean", short: "h", description: "Show this output." },
|
|
json: { type: "boolean" },
|
|
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.",
|
|
},
|
|
|
|
// These two have been deprecated by bindAddr.
|
|
host: { type: "string", description: "" },
|
|
port: { type: "number", description: "" },
|
|
|
|
socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." },
|
|
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[]",
|
|
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." },
|
|
"ignore-last-opened": {
|
|
type: "boolean",
|
|
short: "e",
|
|
description: "Ignore the last opened directory or workspace in favor of an empty window.",
|
|
},
|
|
"new-window": {
|
|
type: "boolean",
|
|
short: "n",
|
|
description: "Force to open a new window.",
|
|
},
|
|
"reuse-window": {
|
|
type: "boolean",
|
|
short: "r",
|
|
description: "Force to open a file or folder in an already opened window.",
|
|
},
|
|
|
|
locale: { type: "string" },
|
|
log: { type: LogLevel },
|
|
verbose: { type: "boolean", short: "vvv", description: "Enable verbose logging." },
|
|
|
|
link: {
|
|
type: OptionalString,
|
|
description: `
|
|
Securely bind 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.
|
|
`,
|
|
beta: true,
|
|
},
|
|
home: {
|
|
type: "string",
|
|
description: "Set a custom link for the 'Go Home' button in the Application Menu",
|
|
},
|
|
}
|
|
|
|
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,
|
|
}),
|
|
{ short: 0, long: 0 },
|
|
)
|
|
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) + (v.beta ? "(beta) " : "") + line
|
|
}
|
|
return " ".repeat(widths.long + widths.short + 6) + line
|
|
})
|
|
.join("\n") +
|
|
(typeof v.type === "object" ? ` [${Object.values(v.type).join(", ")}]` : "")
|
|
)
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
const args: Args = { _: [] }
|
|
let ended = false
|
|
|
|
for (let i = 0; i < argv.length; ++i) {
|
|
const arg = argv[i]
|
|
|
|
// -- signals the end of option parsing.
|
|
if (!ended && arg === "--") {
|
|
ended = true
|
|
continue
|
|
}
|
|
|
|
// 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
|
|
if (arg.startsWith("--")) {
|
|
const split = arg.replace(/^--/, "").split("=", 2)
|
|
key = split[0] as keyof Args
|
|
value = split[1]
|
|
} 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")
|
|
}
|
|
|
|
if (key === "hashed-password" && !opts?.configFile) {
|
|
throw new Error("--hashed-password can only be set in the config file or passed in via $HASHED_PASSWORD")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if (!value && option.type === OptionalString) {
|
|
;(args[key] as OptionalString) = new OptionalString(value)
|
|
continue
|
|
} else if (!value) {
|
|
throw error(`--${key} requires a value`)
|
|
}
|
|
|
|
if (option.type === OptionalString && value === "false") {
|
|
continue
|
|
}
|
|
|
|
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`)
|
|
}
|
|
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(", ")}]`)
|
|
}
|
|
;(args[key] as string) = value
|
|
break
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
// Everything else goes into _.
|
|
args._.push(arg)
|
|
}
|
|
|
|
// If a cert was provided a key must also be provided.
|
|
if (args.cert && args.cert.value && !args["cert-key"]) {
|
|
throw new Error("--cert-key is missing")
|
|
}
|
|
|
|
logger.debug(() => ["parsed command line", field("args", { ...args, password: undefined })])
|
|
|
|
return args
|
|
}
|
|
|
|
export interface DefaultedArgs extends ConfigArgs {
|
|
auth: AuthType
|
|
cert?: {
|
|
value: string
|
|
}
|
|
host: string
|
|
port: number
|
|
"proxy-domain": string[]
|
|
verbose: boolean
|
|
usingEnvPassword: boolean
|
|
usingEnvHashedPassword: boolean
|
|
"extensions-dir": string
|
|
"user-data-dir": string
|
|
}
|
|
|
|
/**
|
|
* Take CLI and config arguments (optional) and return a single set of arguments
|
|
* with the defaults set. Arguments from the CLI are prioritized over config
|
|
* arguments.
|
|
*/
|
|
export async function setDefaults(cliArgs: Args, configArgs?: ConfigArgs): Promise<DefaultedArgs> {
|
|
const args = Object.assign({}, configArgs || {}, cliArgs)
|
|
|
|
if (!args["user-data-dir"]) {
|
|
await copyOldMacOSDataDir()
|
|
args["user-data-dir"] = paths.data
|
|
}
|
|
|
|
if (!args["extensions-dir"]) {
|
|
args["extensions-dir"] = path.join(args["user-data-dir"], "extensions")
|
|
}
|
|
|
|
// --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)
|
|
) {
|
|
args.log = process.env.LOG_LEVEL as LogLevel
|
|
}
|
|
|
|
// Sync --log, --verbose, the environment variable, and logger level.
|
|
if (args.log) {
|
|
process.env.LOG_LEVEL = args.log
|
|
}
|
|
switch (args.log) {
|
|
case LogLevel.Trace:
|
|
logger.level = Level.Trace
|
|
args.verbose = true
|
|
break
|
|
case LogLevel.Debug:
|
|
logger.level = Level.Debug
|
|
args.verbose = false
|
|
break
|
|
case LogLevel.Info:
|
|
logger.level = Level.Info
|
|
args.verbose = false
|
|
break
|
|
case LogLevel.Warn:
|
|
logger.level = Level.Warning
|
|
args.verbose = false
|
|
break
|
|
case LogLevel.Error:
|
|
logger.level = Level.Error
|
|
args.verbose = false
|
|
break
|
|
}
|
|
|
|
// Default to using a password.
|
|
if (!args.auth) {
|
|
args.auth = AuthType.Password
|
|
}
|
|
|
|
const addr = bindAddrFromAllSources(configArgs || { _: [] }, cliArgs)
|
|
args.host = addr.host
|
|
args.port = addr.port
|
|
|
|
// If we're being exposed to the cloud, we listen on a random address and
|
|
// disable auth.
|
|
if (args.link) {
|
|
args.host = "localhost"
|
|
args.port = 0
|
|
args.socket = undefined
|
|
args.cert = undefined
|
|
args.auth = AuthType.None
|
|
}
|
|
|
|
if (args.cert && !args.cert.value) {
|
|
const { cert, certKey } = await generateCertificate(args["cert-host"] || "localhost")
|
|
args.cert = {
|
|
value: cert,
|
|
}
|
|
args["cert-key"] = certKey
|
|
}
|
|
|
|
let usingEnvPassword = !!process.env.PASSWORD
|
|
if (process.env.PASSWORD) {
|
|
args.password = process.env.PASSWORD
|
|
}
|
|
|
|
const usingEnvHashedPassword = !!process.env.HASHED_PASSWORD
|
|
if (process.env.HASHED_PASSWORD) {
|
|
args["hashed-password"] = process.env.HASHED_PASSWORD
|
|
usingEnvPassword = false
|
|
}
|
|
|
|
// Ensure they're not readable by child processes.
|
|
delete process.env.PASSWORD
|
|
delete process.env.HASHED_PASSWORD
|
|
|
|
// Filter duplicate proxy domains and remove any leading `*.`.
|
|
const proxyDomains = new Set((args["proxy-domain"] || []).map((d) => d.replace(/^\*\./, "")))
|
|
args["proxy-domain"] = Array.from(proxyDomains)
|
|
|
|
return {
|
|
...args,
|
|
usingEnvPassword,
|
|
usingEnvHashedPassword,
|
|
} as DefaultedArgs // TODO: Technically no guarantee this is fulfilled.
|
|
}
|
|
|
|
async function defaultConfigFile(): Promise<string> {
|
|
return `bind-addr: 127.0.0.1:8080
|
|
auth: password
|
|
password: ${await generatePassword()}
|
|
cert: false
|
|
`
|
|
}
|
|
|
|
interface ConfigArgs extends Args {
|
|
config: string
|
|
}
|
|
|
|
/**
|
|
* 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<ConfigArgs> {
|
|
if (!configPath) {
|
|
configPath = process.env.CODE_SERVER_CONFIG
|
|
if (!configPath) {
|
|
configPath = path.join(paths.config, "config.yaml")
|
|
}
|
|
}
|
|
|
|
if (!(await fs.pathExists(configPath))) {
|
|
await fs.outputFile(configPath, await defaultConfigFile())
|
|
logger.info(`Wrote default config file to ${humanPath(configPath)}`)
|
|
}
|
|
|
|
const configFile = await fs.readFile(configPath)
|
|
return parseConfigFile(configFile.toString(), configPath)
|
|
}
|
|
|
|
/**
|
|
* parseConfigFile parses configFile into ConfigArgs.
|
|
* configPath is used as the filename in error messages
|
|
*/
|
|
export function parseConfigFile(configFile: string, configPath: string): ConfigArgs {
|
|
if (!configFile) {
|
|
return { _: [], config: configPath }
|
|
}
|
|
|
|
const config = yaml.safeLoad(configFile, {
|
|
filename: configPath,
|
|
})
|
|
if (!config || typeof config === "string") {
|
|
throw new Error(`invalid config: ${config}`)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
function parseBindAddr(bindAddr: string): Addr {
|
|
const u = new URL(`http://${bindAddr}`)
|
|
return {
|
|
host: u.hostname,
|
|
// 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.
|
|
port: u.port ? parseInt(u.port, 10) : 80,
|
|
}
|
|
}
|
|
|
|
interface Addr {
|
|
host: string
|
|
port: number
|
|
}
|
|
|
|
function bindAddrFromArgs(addr: Addr, args: Args): Addr {
|
|
addr = { ...addr }
|
|
if (args["bind-addr"]) {
|
|
addr = parseBindAddr(args["bind-addr"])
|
|
}
|
|
if (args.host) {
|
|
addr.host = args.host
|
|
}
|
|
|
|
if (process.env.PORT) {
|
|
addr.port = parseInt(process.env.PORT, 10)
|
|
}
|
|
if (args.port !== undefined) {
|
|
addr.port = args.port
|
|
}
|
|
return addr
|
|
}
|
|
|
|
function bindAddrFromAllSources(...argsConfig: Args[]): Addr {
|
|
let addr: Addr = {
|
|
host: "localhost",
|
|
port: 8080,
|
|
}
|
|
|
|
for (const args of argsConfig) {
|
|
addr = bindAddrFromArgs(addr, args)
|
|
}
|
|
|
|
return addr
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
export const shouldRunVsCodeCli = (args: Args): boolean => {
|
|
return !!args["list-extensions"] || !!args["install-extension"] || !!args["uninstall-extension"]
|
|
}
|
|
|
|
/**
|
|
* Determine if it looks like the user is trying to open a file or folder in an
|
|
* existing instance. The arguments here should be the arguments the user
|
|
* explicitly passed on the command line, not defaults or the configuration.
|
|
*/
|
|
export const shouldOpenInExistingInstance = async (args: Args): Promise<string | undefined> => {
|
|
// Always use the existing instance if we're running from VS Code's terminal.
|
|
if (process.env.VSCODE_IPC_HOOK_CLI) {
|
|
return process.env.VSCODE_IPC_HOOK_CLI
|
|
}
|
|
|
|
const readSocketPath = async (): Promise<string | undefined> => {
|
|
try {
|
|
return await fs.readFile(path.join(os.tmpdir(), "vscode-ipc"), "utf8")
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") {
|
|
throw error
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// If these flags are set then assume the user is trying to open in an
|
|
// existing instance since these flags have no effect otherwise.
|
|
const openInFlagCount = ["reuse-window", "new-window"].reduce((prev, cur) => {
|
|
return args[cur as keyof Args] ? prev + 1 : prev
|
|
}, 0)
|
|
if (openInFlagCount > 0) {
|
|
return readSocketPath()
|
|
}
|
|
|
|
// It's possible the user is trying to spawn another instance of code-server.
|
|
// Check if any unrelated flags are set (check against one because `_` always
|
|
// exists), that a file or directory was passed, and that the socket is
|
|
// active.
|
|
if (Object.keys(args).length === 1 && args._.length > 0) {
|
|
const socketPath = await readSocketPath()
|
|
if (socketPath && (await canConnect(socketPath))) {
|
|
return socketPath
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|