GCP運用を便利にするHyperのpluginを作った話

こんにちは、オールアバウト SRE所属の @ishii1648 です。

この記事は、[All About Group（株式会社オールアバウト） Advent Calendar 2020](https://qiita.com/advent-calendar/2020/allabout) 16日目の記事です。

## 概要

オールアバウトではクラウドインフラとして GCP を利用しており、私の所属しているSREチームは各サービスの環境を横断的に管理しています。

私の普段の業務は、DEV環境用のプロジェクトで検証を進め、検証が完了したら STG 環境、本番環境にリリースするという流れになっています（そういう方は多いと思います）。そのため多くの時間 gcloud コマンドの接続先はDEV環境のプロジェクトに向いています。しかしリリース後に接続先を戻し忘れると本番環境に接続したままになり、気をつけないと事故に繋がる恐れがあります。

戻し忘れや、コマンド実行時に注意すれば済む話しですが、自分の注意力に期待するよりも環境改善するほうがエンジニアとして健全だろうと思い Terminal の Plugin を作ることにしました。

## 前提

- Hyper を使ったことがある（インストールはしたぜ！くらいでも大丈夫です）
- Javascript の基本を理解している

ちなみに Hyper とは Windows・Mac・Linuxで利用できる Terminal 環境で以下ページからインストールできます。
https://hyper.is/

## Plugin の機能概要

- 接続先のプロジェクトを表示
- 接続先のKubernetesコンテキストを表示
- DEV環境以外のプロジェクト接続時はColorスキーマを変更

## Pluginを作るまでの経緯

Hyperでは多くのPluginが公開されているので自作する必要も無いかなと思いましたが、上記の仕様を完全に満たすPluginが無かったので結局自作することになりました。

ただし類似のPluginは既に存在したので、フォークする形で利用させて頂きました。
ここで紹介させて頂く内容の基本実装は全て下記リポジトリ開発者の方に依るものです。

https://github.com/marcjoha/hyper-gcp-status-line

## Plugin の作り方

Hyperは Electron & React で動作しています。Reactの実装ではHOC（Higher-order Components）が採用されており、PluginではHOCで返されてきたコンポーネントをカスタマイズする事でHyperの動作に干渉することができます。

### プロジェクトの作り方

以下、公式で非常に丁寧に説明されていますので、詳しくは以下をご覧ください。

https://github.com/ishii1648/hyper/blob/canary/PLUGINS.md

ここでは最低限必要な内容を解説していきます。

#### 1. Hyperを開発モードで起動する

まずは公式のHyperをフォークします。
（必須ではありませんが、後でコードを追加するのでフォークしておいたほうが管理上都合が良いです）

https://github.com/vercel/hyper

上記でフォークしたリポジトリをローカル環境にcloneし、canaryブランチにcheckoutした後にリポジトリ直下で以下コマンドを実行すると開発モードで起動できます。

```
$ yarn
$ yarn run app
```

開発モードで動作できることを確認したら一旦、停止しておきます。

#### 2. 設定ファイルを配置する

ユーザディレクトリ直下に配置されている設定ファイル（.hyper.js）をリポジトリ直下にコピーし、以下のように開発用の設定を追加してください。

```javascript
module.exports = {
  config: {
    ...
  },
  plugins: [],
  localPlugins: ['プラグイン名'], <-- 追加する箇所
  ...
}
```

#### 3. Pluginを配置する

Hyperは開発モードでの起動時に以下ディレクトリに配置されている内容をlocalPluginとして読み込もうとします。

```
.hyper_plugins/local/
```

なのでここに開発するPluginを配置します。以下は私の実際の開発環境です。

![スクリーンショット 2020-12-15 6.12.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206595/503c8aac-6d40-6d66-7188-b249f819341d.png)

直接 .hyper_plugins/local/ 配下にプロジェクトを作成してしまっても良いのですが、それだと何かと不便なので私の場合は別ディレクトリにプロジェクト作成して以下コマンドでシンボリックリンクを貼っています。

```
ln -s ~/src/github.com/ishii1648/hyper-gcp-status-line ~/src/github.com/ishii1648/hyper/.hyper_plugins/local/
```

#### 4. 実装

ここまで来たら後はひたすらコーディングです。今回は私の作った Plugin のコードをもとに Hyper の CSS を動的に変更する方法を解説します。

以下リポジトリと動作イメージになります。

https://github.com/ishii1648/hyper-gcp-k8s-info-line/

![hyper-gcp-k8s-info-line.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206595/f9b5dad6-0cce-28f8-649d-6377e5d0214b.gif)

それではコードの解説を始めていきます。

まずは表示内容を記述する部分です。表示用のAPIは幾つか用意されていますが、以下では decorateHyper を利用しています。Terminalの下部に表示したかったので、footerにDOMを追加しています。

この辺りの用意されているAPIや画面のDOM構成はHyperの[公式ページ](https://hyper.is/)で解説されています。

```javascript
exports.decorateHyper = (Hyper, { React, notify }) => {
    return class extends React.PureComponent {
        constructor(props) {
            super(props);
            this.state = {};
        }

        render() {
            const { customChildren } = this.props;
            const existingChildren = customChildren ? customChildren instanceof Array ? customChildren : [customChildren] : [];

            return (
                React.createElement(Hyper, Object.assign({}, this.props, {
                    customInnerChildren: existingChildren.concat(React.createElement('footer', { className: 'hyper-gcp-status-line' },
                        React.createElement('div', { className: 'item gcp-project', title: 'GCP project' }, this.state.gcpProject),
                        React.createElement('div', { className: 'item kubernetes-context', title: 'Kubernetes context and namespace' }, this.state.kubernetesContext),
                    ))
                }))
            );
        }

        componentDidMount() {
            // Check configuration, and kick off timer to watch for updates
            setConfiguration();
            this.repaintInterval = setInterval(() => {
                this.setState(state);
            }, 100);
        }

        componentWillUnmount() {
            clearInterval(this.repaintInterval);
        }
    };
}
```

ここで Redux の処理に介入しています。GCPプロジェクトのDEV環境以外への変更を検知したら、本番兼STG環境用のカラースキーマを dispatch します。

```javascript
exports.middleware = (store) => (next) => (action) => {
    switch (action.type) {
        case 'SESSION_ADD_DATA':
            if (action.data.indexOf('\n') > 1) {
                setConfiguration();

                if (state.isChangeEnv) {
                    store.dispatch({
                        type: 'CONFIG_RELOAD',
                        config: { prdColorScheme: productionColorScheme }
                    });
                    state.isChangeEnv = false
                }
            }
            break;

        case 'SESSION_SET_ACTIVE':
            setConfiguration();
            break;
    }

    next(action);
}
```

プロジェクトの変更検知は以下のGCPプロジェクト取得処理時に併せて実施しています。

```javascript
function setGcpProject() {
    exec("cat " + configuration.gcpConfigurePath + " | grep project", (error, stdout, stderr) => {
        if (error) {
            state.gcpProject = 'n/a';
            return
        }

        oldGcpProject = state.gcpProject
        project = stdout.split("=")[1].trim()
        state.gcpProject = project

        if (oldGcpProject != state.gcpProject) {
            state.isChangeEnv = true
        }
    })
}
```

dispatchされたカラースキーマは以下の reduceUI で取得して state に追加します。

```javascript
exports.reduceUI = (state_, { type, config }) => {
    switch (type) {
        case 'CONFIG_LOAD':
            if (config.hasOwnProperty('hyperGcpKubernetesInfoLine')) {
                Object.assign(configuration, config.hyperGcpKubernetesInfoLine)
            }
            let initialColorScheme = {}
            Object.keys(productionColorScheme).forEach((key) => {
                initialColorScheme[key] = state_[key]
            })
            return state_.set('initialColorScheme', initialColorScheme)
        case 'CONFIG_RELOAD': {
            if (config.hasOwnProperty('hyperGcpKubernetesInfoLine')) {
                Object.assign(configuration, config.hyperGcpKubernetesInfoLine)
            }
            if (!config.hasOwnProperty('prdColorScheme')) {
                return state_
            }
            if (state.gcpProject.indexOf(configuration.devGCPProjects) > -1) {
                return state_.set('prdColorScheme', {empty: true})
            }

            return state_.set('prdColorScheme', config.prdColorScheme)
        }
    }

    return state_
}
```

最後に以下で props にマージしてやります。これで Hyperの CSS が書き換わります。

```javascript
exports.mapTermsState = (state, map) => {
    if (!state.ui.prdColorScheme) {
        return map;
    }
    if (Object.keys(state.ui.prdColorScheme).indexOf('empty') > -1) {
        return Object.assign({}, map, {profiles: state.ui.initialColorScheme});
    }
    return Object.assign({}, map, {profiles: state.ui.prdColorScheme});
}

exports.getTermGroupProps = (uid, parentProps, props) => {
    const {profiles} = parentProps
    if (!profiles) {
        return props
    }
    const profileProps = Object.assign({}, profiles);

    return Object.assign({}, props, profileProps);
};
```

## Pluginの公開方法

HyperのPluginの実体はnpmのモジュールです。なのでnpmモジュールとして公開することでインストールできるようになります。
npmの公開方法は以下記事で詳しく解説されています。

https://qiita.com/TsutomuNakamura/items/f943e0490d509f128ae2

## まとめ

Hyper は Javascript で作られているため、多くのエンジニアにとってカスタマイズがしやすいのが素晴らしい点です。また、今回ご紹介したように非常に簡素な実装で Plugin を作ることができるため、Javascriptにそれほど精通していない方でも見様見真似で充分実装できます。

何より自分で実装したツールが生産性を改善してくれるのはとても気持ちの良いものです。是非これを機に Hyper の Plugin を開発してみてください。