diff --git a/TODO.md b/TODO.md index 0507f5c..eab20e5 100644 --- a/TODO.md +++ b/TODO.md @@ -3,5 +3,6 @@ - Write to env? - Test with AWS Extension - See if this can set AWS Builder ID for AWS Extension + - Try to invoke AWS Select Connection afterwards - Onboarding experience for brand new user / org -- Don't rely on the `aws` CLI to update profile +- Loading progress indicator between inputs diff --git a/package.json b/package.json index 7bc3588..09d2318 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,9 @@ }, "devDependencies": { "@scaffoldly/openapi-generator": "^2.0.0", + "@types/fs-extra": "^11.0.4", "@types/humanize-duration": "^3.27.3", + "@types/ini": "^1.3.34", "@types/mocha": "^10.0.3", "@types/node": "18.x", "@types/qrcode-svg": "^1.1.4", @@ -135,8 +137,10 @@ "@aws-sdk/client-sts": "^3.458.0", "@octokit/rest": "^20.0.2", "axios": "^1.6.2", + "fs-extra": "^11.2.0", "humanize-duration": "^3.31.0", + "ini": "^4.1.1", "qrcode-svg": "^1.1.0", "which": "^4.0.0" } -} \ No newline at end of file +} diff --git a/src/aws.ts b/src/aws.ts index 2820e05..f620bb0 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -1,11 +1,16 @@ import * as vscode from "vscode"; +import * as ini from "ini"; import { ApiFactory } from "./api"; import { TotpHelper } from "./totp"; import { AssumeRoleWithSAMLCommand, STSClient } from "@aws-sdk/client-sts"; -import { exec } from "./exec"; +// import { exec } from "./exec"; import humanizeDuration from "humanize-duration"; import { AwsRoleSelection, Configuration, ProfileName } from "./config"; import { isAxiosError } from "axios"; +import { dirname, join } from "path"; +import { mkdirp } from "fs-extra"; +import { fileExists, getHomeDirectory } from "./util"; +import { readFileSync, writeFileSync } from "fs"; export const assumeAwsRole = ( configuration: Configuration, @@ -133,14 +138,22 @@ export const assumeAwsRole = ( configuration.assumeAws.profile.name, roleSelection ); - const base = ["aws", "configure"]; - base.push("--profile", profileName); - base.push("set"); - await exec([...base, "region", configuration.assumeAws.region]); - await exec([...base, "aws_access_key_id", AccessKeyId]); - await exec([...base, "aws_secret_access_key", SecretAccessKey]); - await exec([...base, "aws_session_token", SessionToken]); + await updateProfile(profileName, { + region: configuration.assumeAws.region, + accessKeyId: AccessKeyId, + secretAccessKey: SecretAccessKey, + sessionToken: SessionToken, + }); + + // const base = ["aws", "configure"]; + // base.push("--profile", profileName); + // base.push("set"); + + // await exec([...base, "region", configuration.assumeAws.region]); + // await exec([...base, "aws_access_key_id", AccessKeyId]); + // await exec([...base, "aws_secret_access_key", SecretAccessKey]); + // await exec([...base, "aws_session_token", SessionToken]); if (!isRefresh) { vscode.window.showInformationMessage( @@ -229,3 +242,91 @@ export const stopRefresh = (apiFactory: ApiFactory): (() => void) => { } }; }; + +const getCredentialsFilename = (): string => { + return ( + process.env.AWS_SHARED_CREDENTIALS_FILE || + join(getHomeDirectory(), ".aws", "credentials") + ); +}; + +const getConfigFilename = (): string => { + return ( + process.env.AWS_CONFIG_FILE || join(getHomeDirectory(), ".aws", "config") + ); +}; + +const getConfigAndCredentials = async (): Promise<{ + config: { [key: string]: any }; + configFile: string; + credentials: { [key: string]: any }; + credentialsFile: string; +}> => { + const configFile = getConfigFilename(); + const credentialsFile = getCredentialsFilename(); + + const config = (await fileExists(configFile)) + ? ini.parse(readFileSync(configFile, "utf-8")) + : {}; + + const credentials = (await fileExists(credentialsFile)) + ? ini.parse(readFileSync(credentialsFile, "utf-8")) + : {}; + + return { config, configFile, credentials, credentialsFile }; +}; + +const updateProfile = async ( + profile: string, + options: { + region: string; + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + } +): Promise => { + const filepath = dirname(getCredentialsFilename()); + if (!(await fileExists(filepath))) { + await mkdirp(filepath); + } + + const { config, configFile, credentials, credentialsFile } = + await getConfigAndCredentials(); + + let section: string | undefined = undefined; + let profileHeader = `profile ${profile}`; + + // Prevent escaping of "." in profile name + if (profile.indexOf(".") !== -1) { + const parts = profile.split("."); + profile = parts.pop() || ""; + profileHeader = profile; + section = `profile ${parts.join(".")}`; + } + + config[profileHeader] = { + region: options.region, + }; + + credentials[profile] = { + aws_access_key_id: options.accessKeyId, + aws_secret_access_key: options.secretAccessKey, + aws_session_token: options.sessionToken, + }; + + writeFileSync( + configFile, + ini.stringify(config, { + whitespace: true, + section, + }) + ); + + writeFileSync( + credentialsFile, + ini.stringify(credentials, { + whitespace: true, + section: section ? section.replace("profile ", "") : undefined, + }) + ); +}; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..6ac32f9 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import * as os from "os"; + +const hasCode = (error: T): error is T & { code: string } => { + return typeof (error as { code?: unknown }).code === "string"; +}; + +const isFileNotFoundError = (err: unknown): boolean => { + if (err instanceof vscode.FileSystemError) { + return err.code === vscode.FileSystemError.FileNotFound().code; + } else if (hasCode(err)) { + return err.code === "ENOENT"; + } + + return false; +}; + +export const fileExists = async ( + file: string | vscode.Uri +): Promise => { + const uri = typeof file === "string" ? vscode.Uri.file(file) : file; + + return vscode.workspace.fs.stat(uri).then( + () => true, + (err) => !isFileNotFoundError(err) + ); +}; + +export const getHomeDirectory = (): string => { + if (process.env.HOME !== undefined) { + return process.env.HOME; + } + if (process.env.USERPROFILE !== undefined) { + return process.env.USERPROFILE; + } + if (process.env.HOMEPATH !== undefined) { + const homeDrive: string = process.env.HOMEDRIVE || "C:"; + + return path.join(homeDrive, process.env.HOMEPATH); + } + + return os.homedir(); +}; diff --git a/yarn.lock b/yarn.lock index 8e814e2..b992f5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1043,16 +1043,36 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/fs-extra@^11.0.4": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" + integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + "@types/humanize-duration@^3.27.3": version "3.27.3" resolved "https://registry.yarnpkg.com/@types/humanize-duration/-/humanize-duration-3.27.3.tgz#fa49ada1cc65222d5f1295c7756b6877f5a96bcf" integrity sha512-wiiiFYjnrYDJE/ujU7wS/NShqp12IKrejozjDtcejP0zYi+cjyjVcfZHwcFUDKVJ7tHGsmgeW2ED92ABIIjfpg== +"@types/ini@^1.3.34": + version "1.3.34" + resolved "https://registry.yarnpkg.com/@types/ini/-/ini-1.3.34.tgz#99a69ecfccdfc3f6e91b411d4208aaa3c4cc9685" + integrity sha512-FafeLhwmWucTi31ZYg/6aHBZNyrogQ35aDvSW7zMAz3HMhUqQ4G/NBya8c5pe2jwoYsDFwra8O9/yZotong76g== + "@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.8": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/jsonfile@*": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.4.tgz#614afec1a1164e7d670b4a7ad64df3e7beb7b702" + integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ== + dependencies: + "@types/node" "*" + "@types/mocha@^10.0.3": version "10.0.6" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.6.tgz#818551d39113081048bdddbef96701b4e8bb9d1b" @@ -2207,6 +2227,15 @@ fs-extra@^10.1.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2459,6 +2488,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ini@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.1.tgz#d95b3d843b1e906e56d6747d5447904ff50ce7a1" + integrity sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g== + ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"