diff --git a/.changeset/chilly-apes-travel.md b/.changeset/chilly-apes-travel.md new file mode 100644 index 000000000..9bd83248c --- /dev/null +++ b/.changeset/chilly-apes-travel.md @@ -0,0 +1,5 @@ +--- +"fnm": minor +--- + +Add `--json` to `fnm env` to output the env vars as JSON diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3162f61e6..30164245e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -159,6 +159,40 @@ jobs: FNM_TARGET_NAME: "release" FORCE_COLOR: "1" + # e2e_windows_debug: + # runs-on: windows-latest + # name: "e2e/windows/debug" + # environment: Debug + # needs: [e2e_windows] + # if: contains(join(needs.*.result, ','), 'failure') + # steps: + # - uses: actions/checkout@v3 + # - uses: actions/download-artifact@v3 + # with: + # name: fnm-windows + # path: target/release + # - uses: pnpm/action-setup@v2.2.2 + # with: + # run_install: false + # - uses: actions/setup-node@v3 + # with: + # node-version: 16.x + # cache: 'pnpm' + # - name: Get pnpm store directory + # id: pnpm-cache + # run: | + # echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + # - uses: actions/cache@v3 + # name: Setup pnpm cache + # with: + # path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + # key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + # restore-keys: | + # ${{ runner.os }}-pnpm-store- + # - run: pnpm install + # - name: 🐛 Debug Build + # uses: mxschmitt/action-tmate@v3 + e2e_linux: runs-on: ubuntu-latest needs: [build_static_linux_binary] diff --git a/docs/commands.md b/docs/commands.md index 49b4ccc22..186e1f99b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -325,6 +325,9 @@ OPTIONS: -h, --help Print help information + --json + Print JSON instead of shell commands + --log-level The log level of fnm commands diff --git a/e2e/__snapshots__/env.test.ts.snap b/e2e/__snapshots__/env.test.ts.snap new file mode 100644 index 000000000..1ad8405f6 --- /dev/null +++ b/e2e/__snapshots__/env.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bash outputs json: Bash 1`] = ` +"set -e +fnm env --json > file.json" +`; + +exports[`Fish outputs json: Fish 1`] = `"fnm env --json > file.json"`; + +exports[`PowerShell outputs json: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env --json | Out-File file.json -Encoding UTF8" +`; + +exports[`Zsh outputs json: Zsh 1`] = ` +"set -e +fnm env --json > file.json" +`; diff --git a/e2e/env.test.ts b/e2e/env.test.ts new file mode 100644 index 000000000..a66fab5c3 --- /dev/null +++ b/e2e/env.test.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { script } from "./shellcode/script" +import { Bash, Fish, PowerShell, WinCmd, Zsh } from "./shellcode/shells" +import testCwd from "./shellcode/test-cwd" +import describe from "./describe" + +for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) { + describe(shell, () => { + test(`outputs json`, async () => { + const filename = `file.json` + await script(shell) + .then( + shell.redirectOutput(shell.call("fnm", ["env", "--json"]), { + output: filename, + }) + ) + .takeSnapshot(shell) + .execute(shell) + + if (shell.currentlySupported()) { + const file = await readFile(join(testCwd(), filename), "utf8") + expect(JSON.parse(file)).toEqual({ + FNM_ARCH: expect.any(String), + FNM_DIR: expect.any(String), + FNM_LOGLEVEL: "info", + FNM_MULTISHELL_PATH: expect.any(String), + FNM_NODE_DIST_MIRROR: expect.any(String), + FNM_VERSION_FILE_STRATEGY: "local", + }) + } + }) + }) +} diff --git a/e2e/shellcode/script.ts b/e2e/shellcode/script.ts index e3e092e37..49912d331 100644 --- a/e2e/shellcode/script.ts +++ b/e2e/shellcode/script.ts @@ -40,7 +40,10 @@ class Script { const args = [...shell.launchArgs()] if (shell.forceFile) { - const filename = join(testTmpDir(), "script") + let filename = join(testTmpDir(), "script") + if (typeof shell.forceFile === "string") { + filename = filename + shell.forceFile + } await writeFile(filename, [...this.lines, "exit 0"].join("\n")) args.push(filename) } @@ -105,8 +108,8 @@ function streamOutputsAndBuffer(child: execa.ExecaChildProcess) { const testName = expect.getState().currentTestName ?? "unknown" const testPath = expect.getState().testPath ?? "unknown" - const stdoutPrefix = chalk.yellow.dim(`[stdout] ${testPath}/${testName}: `) - const stderrPrefix = chalk.red.dim(`[stderr] ${testPath}/${testName}: `) + const stdoutPrefix = chalk.cyan.dim(`[stdout] ${testPath}/${testName}: `) + const stderrPrefix = chalk.magenta.dim(`[stderr] ${testPath}/${testName}: `) if (child.stdout) { child.stdout.on("data", (data) => { diff --git a/e2e/shellcode/shells/index.ts b/e2e/shellcode/shells/index.ts index a9a84f905..7400d7b6f 100644 --- a/e2e/shellcode/shells/index.ts +++ b/e2e/shellcode/shells/index.ts @@ -34,6 +34,7 @@ export const Zsh = { }), ...cmdEnv.bash, ...cmdCall.all, + ...redirectOutput.bash, ...cmdExpectCommandOutput.bash, ...cmdHasOutputContains.bash, ...cmdInSubShell.zsh, @@ -58,14 +59,8 @@ export const Fish = { export const PowerShell = { ...define({ - binaryName: () => { - if (process.platform === "win32") { - return "powershell.exe" - } else { - return "pwsh" - } - }, - forceFile: true, + binaryName: () => "pwsh", + forceFile: ".ps1", currentlySupported: () => true, name: () => "PowerShell", launchArgs: () => ["-NoProfile"], @@ -74,6 +69,7 @@ export const PowerShell = { }), ...cmdEnv.powershell, ...cmdCall.all, + ...redirectOutput.powershell, ...cmdExpectCommandOutput.powershell, ...cmdHasOutputContains.powershell, ...cmdInSubShell.powershell, @@ -97,4 +93,5 @@ export const WinCmd = { ...cmdEnv.wincmd, ...cmdCall.all, ...cmdExpectCommandOutput.wincmd, + ...redirectOutput.bash, } diff --git a/e2e/shellcode/shells/redirect-output.ts b/e2e/shellcode/shells/redirect-output.ts index 473bad6c3..a6c1cbd9c 100644 --- a/e2e/shellcode/shells/redirect-output.ts +++ b/e2e/shellcode/shells/redirect-output.ts @@ -7,7 +7,10 @@ export type HasRedirectOutput = { export const redirectOutput = { bash: define({ + redirectOutput: (childCommand, opts) => `${childCommand} > ${opts.output}`, + }), + powershell: define({ redirectOutput: (childCommand, opts) => - `(${childCommand}) > ${opts.output}`, + `${childCommand} | Out-File ${opts.output} -Encoding UTF8`, }), } diff --git a/e2e/shellcode/shells/types.ts b/e2e/shellcode/shells/types.ts index 65c1d145e..21618a648 100644 --- a/e2e/shellcode/shells/types.ts +++ b/e2e/shellcode/shells/types.ts @@ -5,7 +5,7 @@ export type Shell = { name(): string launchArgs(): string[] dieOnErrors?(): string - forceFile?: true + forceFile?: true | string } export type ScriptLine = string diff --git a/e2e/system-node.test.ts b/e2e/system-node.test.ts index 01c5764bf..c92e6d586 100644 --- a/e2e/system-node.test.ts +++ b/e2e/system-node.test.ts @@ -18,7 +18,7 @@ for (const shell of [Bash, Fish, PowerShell, WinCmd, Zsh]) { process.platform === "win32" && [WinCmd, PowerShell].includes(shell) ) { - await fs.writeFile(customNode + ".cmd", '@echo "custom node"') + await fs.writeFile(customNode + ".cmd", "@echo custom") } else { await fs.writeFile(customNode, `#!/bin/bash\n\necho "custom"\n`) // set executable diff --git a/src/commands/env.rs b/src/commands/env.rs index dc3b47f44..6bb4427db 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -6,6 +6,7 @@ use crate::outln; use crate::path_ext::PathExt; use crate::shell::{infer_shell, Shell, AVAILABLE_SHELLS}; use colored::Colorize; +use std::collections::HashMap; use std::fmt::Debug; use thiserror::Error; @@ -15,6 +16,9 @@ pub struct Env { #[clap(long)] #[clap(possible_values = AVAILABLE_SHELLS)] shell: Option>, + /// Print JSON instead of shell commands. + #[clap(long, conflicts_with = "shell")] + json: bool, /// Deprecated. This is the default now. #[clap(long, hide = true)] multi: bool, @@ -59,50 +63,60 @@ impl Command for Env { ); } - let shell: Box = self - .shell - .or_else(infer_shell) - .ok_or(Error::CantInferShell)?; let multishell_path = make_symlink(config)?; let binary_path = if cfg!(windows) { multishell_path.clone() } else { multishell_path.join("bin") }; - println!("{}", shell.path(&binary_path)?); - println!( - "{}", - shell.set_env_var("FNM_MULTISHELL_PATH", multishell_path.to_str().unwrap()) - ); - println!( - "{}", - shell.set_env_var( + + let env_vars = HashMap::from([ + ( + "FNM_MULTISHELL_PATH", + multishell_path.to_str().unwrap().to_owned(), + ), + ( "FNM_VERSION_FILE_STRATEGY", - config.version_file_strategy().as_str() - ) - ); - println!( - "{}", - shell.set_env_var("FNM_DIR", config.base_dir_with_default().to_str().unwrap()) - ); - println!( - "{}", - shell.set_env_var("FNM_LOGLEVEL", config.log_level().clone().into()) - ); - println!( - "{}", - shell.set_env_var("FNM_NODE_DIST_MIRROR", config.node_dist_mirror.as_str()) - ); - println!( - "{}", - shell.set_env_var("FNM_ARCH", &config.arch.to_string()) - ); + config.version_file_strategy().as_str().to_owned(), + ), + ( + "FNM_DIR", + config.base_dir_with_default().to_str().unwrap().to_owned(), + ), + ( + "FNM_LOGLEVEL", + <&'static str>::from(config.log_level().clone()).to_owned(), + ), + ( + "FNM_NODE_DIST_MIRROR", + config.node_dist_mirror.as_str().to_owned(), + ), + ("FNM_ARCH", config.arch.to_string()), + ]); + + if self.json { + println!("{}", serde_json::to_string(&env_vars).unwrap()); + return Ok(()); + } + + let shell: Box = self + .shell + .or_else(infer_shell) + .ok_or(Error::CantInferShell)?; + + println!("{}", shell.path(&binary_path)?); + + for (name, value) in &env_vars { + println!("{}", shell.set_env_var(name, value)); + } + if self.use_on_cd { println!("{}", shell.use_on_cd(config)?); } if let Some(v) = shell.rehash() { println!("{}", v); } + Ok(()) } }