Skip to content

Commit

Permalink
Merge pull request #293 from mittwald/feature/ssh-ux
Browse files Browse the repository at this point in the history
Optimize user experience of ssh-key commands
  • Loading branch information
martin-helmich committed Mar 7, 2024
2 parents 767b1c2 + f8a68d5 commit f3ca562
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 58 deletions.
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ USAGE
* [`mw user ssh-key create`](#mw-user-ssh-key-create)
* [`mw user ssh-key delete ID`](#mw-user-ssh-key-delete-id)
* [`mw user ssh-key get KEY-ID`](#mw-user-ssh-key-get-key-id)
* [`mw user ssh-key import`](#mw-user-ssh-key-import)
* [`mw user ssh-key list`](#mw-user-ssh-key-list)

## `mw app copy [INSTALLATION-ID]`
Expand Down Expand Up @@ -4679,14 +4680,14 @@ Create and import a new SSH key

```
USAGE
$ mw user ssh-key create [-q] [--output <value>] [--no-passphrase] [--comment <value>] [--expiresAt <value>]
$ mw user ssh-key create [-q] [--expires <value>] [--output <value>] [--no-passphrase] [--comment <value>]
FLAGS
-q, --quiet suppress process output and only display a machine-readable summary.
--comment=<value> A comment for the SSH key.
--expiresAt=<value> Duration after which the SSH key should expire (example: '1y').
--no-passphrase Use this flag to not set a passphrase for the SSH key.
--output=<value> [default: mstudio-cli] A filename in your ~/.ssh directory to write the SSH key to.
-q, --quiet suppress process output and only display a machine-readable summary.
--comment=<value> A comment for the SSH key.
--expires=<value> An interval after which the SSH key expires (examples: 30m, 30d, 1y).
--no-passphrase Use this flag to not set a passphrase for the SSH key.
--output=<value> [default: mstudio-cli] A filename in your ~/.ssh directory to write the SSH key to.
DESCRIPTION
Create and import a new SSH key
Expand Down Expand Up @@ -4742,6 +4743,29 @@ DESCRIPTION
Get a specific SSH key
```

## `mw user ssh-key import`

Import an existing (local) SSH key

```
USAGE
$ mw user ssh-key import [-q] [--expires <value>] [--input <value>]
FLAGS
-q, --quiet suppress process output and only display a machine-readable summary.
--expires=<value> An interval after which the SSH key expires (examples: 30m, 30d, 1y).
--input=<value> [default: id_rsa.pub] A filename in your ~/.ssh directory containing the key to import.
DESCRIPTION
Import an existing (local) SSH key
FLAG DESCRIPTIONS
-q, --quiet suppress process output and only display a machine-readable summary.
This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in
scripts), you can use this flag to easily get the IDs of created resources for further processing.
```

## `mw user ssh-key list`

Get your stored ssh keys
Expand Down
95 changes: 43 additions & 52 deletions src/commands/user/ssh-key/create.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Flags } from "@oclif/core";
import { assertStatus } from "@mittwald/api-client-commons";
import * as cp from "child_process";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import parseDuration from "parse-duration";
import { ExecRenderBaseCommand } from "../../../rendering/react/ExecRenderBaseCommand.js";
import {
makeProcessRenderer,
Expand All @@ -13,6 +11,12 @@ import {
import { Success } from "../../../rendering/react/components/Success.js";
import { Filename } from "../../../rendering/react/components/Filename.js";
import { Text } from "ink";
import { ProcessRenderer } from "../../../rendering/process/process.js";
import {
expirationDateFromFlagsOptional,
expireFlags,
} from "../../../lib/expires.js";
import { spawnInProcess } from "../../../rendering/process/process_exec.js";

export default class Create extends ExecRenderBaseCommand<
typeof Create,
Expand All @@ -22,6 +26,7 @@ export default class Create extends ExecRenderBaseCommand<

static flags = {
...processFlags,
...expireFlags("SSH key"),
output: Flags.string({
description:
"A filename in your ~/.ssh directory to write the SSH key to.",
Expand All @@ -33,61 +38,28 @@ export default class Create extends ExecRenderBaseCommand<
comment: Flags.string({
description: "A comment for the SSH key.",
}),
expiresAt: Flags.string({
description:
"Duration after which the SSH key should expire (example: '1y').",
}),
};

protected async exec(): Promise<undefined> {
const { flags } = await this.parse(Create);
const cmd = "ssh-keygen";
const outputFile = path.join(os.homedir(), ".ssh", flags.output);
const args = ["-t", "rsa", "-f", outputFile];

const process = makeProcessRenderer(flags, "Creating a new SSH key");
const outputFile = path.join(os.homedir(), ".ssh", this.flags.output);

let expiresAt: Date | undefined;
const r = makeProcessRenderer(this.flags, "Creating a new SSH key");

if (flags["expiresAt"]) {
const parsedDuration = parseDuration(flags["expiresAt"]);
if (!parsedDuration) {
throw new Error("Invalid duration");
}
const expiresAt = expirationDateFromFlagsOptional(this.flags);
const passphrase = await this.getPassphrase(r);
const args = ["-t", "rsa", "-f", outputFile, "-N", passphrase];

expiresAt = new Date();
expiresAt.setTime(new Date().getTime() + parsedDuration);
if (this.flags.comment) {
args.push("-C", this.flags.comment);
}

if (flags["no-passphrase"]) {
args.push("-N", "");
} else {
const passphrase = await process.addInput(
<Text>enter passphrase for SSH key</Text>,
true,
);
args.push("-N", passphrase);
}
await spawnInProcess(r, "generating SSH key using ssh-keygen", cmd, args);
const publicKey = await fs.readFile(outputFile + ".pub", "utf-8");

if (flags.comment) {
args.push("-C", flags.comment);
}
r.addInfo(<InfoSSHKeySaved filename={outputFile} />);

const publicKey = await process.runStep(
"generating SSH key using ssh-keygen",
async () => {
cp.spawnSync(cmd, args, { stdio: "ignore" });
return await fs.readFile(outputFile + ".pub", "utf-8");
},
);

process.addInfo(
<Text>
ssh key saved to <Filename filename={outputFile} />.
</Text>,
);

await process.runStep("importing SSH key", async () => {
await r.runStep("importing SSH key", async () => {
const response = await this.apiClient.user.createSshKey({
data: {
publicKey,
Expand All @@ -96,17 +68,36 @@ export default class Create extends ExecRenderBaseCommand<
});

assertStatus(response, 201);
return response;
});

process.complete(
<Success>
Your SSH key was successfully created and imported to your user profile.
</Success>,
);
await r.complete(<SSHKeySuccess />);
}

protected render() {
return null;
}

private async getPassphrase(r: ProcessRenderer): Promise<string> {
if (this.flags["no-passphrase"]) {
return "";
}

return await r.addInput(<Text>enter passphrase for SSH key</Text>, true);
}
}

function SSHKeySuccess() {
return (
<Success>
Your SSH key was successfully created and imported to your user profile.
</Success>
);
}

function InfoSSHKeySaved({ filename }: { filename: string }) {
return (
<Text>
ssh key saved to <Filename filename={filename} />.
</Text>
);
}
89 changes: 89 additions & 0 deletions src/commands/user/ssh-key/import.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Flags } from "@oclif/core";
import { assertStatus } from "@mittwald/api-client-commons";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import { ExecRenderBaseCommand } from "../../../rendering/react/ExecRenderBaseCommand.js";
import {
makeProcessRenderer,
processFlags,
} from "../../../rendering/process/process_flags.js";
import { Success } from "../../../rendering/react/components/Success.js";
import { Filename } from "../../../rendering/react/components/Filename.js";
import {
expirationDateFromFlagsOptional,
expireFlags,
} from "../../../lib/expires.js";

export default class Import extends ExecRenderBaseCommand<
typeof Import,
undefined
> {
static description = "Import an existing (local) SSH key";

static flags = {
...processFlags,
...expireFlags("SSH key"),
input: Flags.string({
description:
"A filename in your ~/.ssh directory containing the key to import.",
default: "id_rsa.pub",
}),
};

protected async exec(): Promise<undefined> {
const inputFile = path.join(os.homedir(), ".ssh", this.flags.input);

const r = makeProcessRenderer(this.flags, "Importing an SSH key");

const expiresAt = expirationDateFromFlagsOptional(this.flags);
const publicKey = await fs.readFile(inputFile, "utf-8");
const publicKeyParts = publicKey.split(" ");

const keys = await r.runStep("retrieving existing SSH keys", async () => {
const response = await this.apiClient.user.listSshKeys();
assertStatus(response, 200);

return response.data;
});

const keyAlreadyExists = (keys.sshKeys ?? []).some(({ key }) =>
publicKeyParts.includes(key),
);

if (keyAlreadyExists) {
r.addInfo(
<>
the SSH key <Filename filename={inputFile} /> is already imported.
</>,
);
await r.complete(<SSHKeySuccess />);
return;
}

await r.runStep("importing SSH key", async () => {
const response = await this.apiClient.user.createSshKey({
data: {
publicKey,
expiresAt: expiresAt?.toJSON(),
},
});

assertStatus(response, 201);
});

await r.complete(<SSHKeySuccess />);
}

protected render() {
return null;
}
}

function SSHKeySuccess() {
return (
<Success>
Your SSH key was successfully read and imported to your user profile.
</Success>
);
}

0 comments on commit f3ca562

Please sign in to comment.