Salesforce CLIのsfプラグインを作成する

# はじめに
Salesforce CLIはコマンドラインからSalesforce開発に必要な操作を実行できるツールです。
プラグインをインストールすることで機能を拡張することもできます。
プラグイン作成用のツールが用意されているので、意外と簡単にプラグインを自作することができます。

イベントモニタリングのログを出力するプラグインを例にプラグインの作成方法について説明します。

# 事前準備
プラグインを作成するのに下記のツールが必要です。

- [Node.js（LTS）](https://nodejs.org/ja)
- [Yarn package manager](https://classic.yarnpkg.com/lang/en/docs/install)
- [Salesforce cli（最新）](https://developer.salesforce.com/ja/tools/sfdxcli)
- plugin-dev

インストールしているSalesforce CLIのバージョンが古い場合は下記のコマンドでアップデートしてください。

```console
$ sfdx update
```

plugin-devはSalesforce CLIプラグインを作成するためのツールです。このツール自体がプラグインです。
下記のコマンドでインストールできます。

```console
$ sf plugins install @salesforce/plugin-dev
```

# プラグインの作成
## 作成の流れ
1. プロジェクトフォルダを作成する
1. コマンドを作成する
1. 必要に応じてコマンドにフラグを追加する
1. 実装する
1. コンパイルする
1. Salesforce CLIにリンクする

## 1. プロジェクトフォルダの作成
plugin-devツールを使用してプロジェクトフォルダを作成します。
下記のコマンドを実行するといくつか質問が表示されるので、その質問に答えることでプロジェクトフォルダが作成されます。
最初の質問はSalesforceの内部開発者かどうか問われるので`n`と答えます。そのほかの質問でツールの名称、説明、作成者、必要なコードカバレッジ率などを入力します。

```console
$ sf dev generate plugin
```

![sf-plugin001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/f84dd75f-0ab9-368d-1fd4-59eb6d30cdea.png)

つぎに依存パッケージをインストールします。

```console
$ yarn install
```

ここまでの作業でプロジェクトフォルダの準備は完了です。

### コマンドの実行方法
プロジェクトフォルダには`hello world`というサンプルコマンドが作成されています。
サンプルコマンドの構成は下記になります。

- src/commands/hello/world.ts: メインの TypeScript ファイル。
- messages/hello.world.md: コマンドのヘルプとエラーを構成するメッセージを含むファイル。
- test/commands/hello/world.nut.ts: 非単体テスト。
- test/commands/hello/world.test.ts: 単体テスト。

こちらのツールを使用してコマンドの実行方法を説明します。
開発中のツールを実行する場合、下記のように`bin/dev`を実行します。

```console
$ bin/dev hello world
Hello World at Mon Apr 10 2023.
```

通常のsfコマンドのように実行するためには、コンパイルを行なって、Salesforce CLIにリンクする必要があります。

```console
$ yarn compile
$ sf plugins link .
$ sf hello world
Hello World at Mon Apr 10 2023.
```

Salesforce CLIにリンクされているsfプラグインを確認するにはpluginsコマンドを実行します。

```console:リンク済みプラグインの確認
$ sf plugins
dev 0.7.0
my-plugin 0.0.1 (link) /Users/tool/sf-plugins/my-plugin
```

Salesforce CLIのプラグインのリンクを解除する場合は`plugins unlink`コマンドを実行します。

```console:プラグインのリンク解除
$ sf plugins unlink プラグイン名
```

## 2. コマンドの作成
EventMonitoringLogを出力するコマンドを作成していきます。
コマンド名のガイドラインは[公式開発者ガイド](https://github.com/salesforcecli/cli/wiki/Design-Your-Plugin#general-naming-guidelines)に記載があります。

```console:作成するコマンド例
$ sf elf export --connected-org org-alias --date-range Last_n_Days:2 --output-dir ./output
```

下記のコマンドを実行してコマンド本体のTypeScriptファイルを作成します。

```console
$ # sf dev generate command --name トピック名:コマンド名
$ sf dev generate command --name elf:export
```

![sf-plugin002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/a218551a-b76e-752b-f307-bd984f71d2a3.png)

このコマンドによって下記のファイルが作成されます。

- src/commands/elf/export.ts
- messages/elf.export.md
- test/commands/elf/export.nut.ts
- test/commands/elf/export.test.ts

## 3. フラグの追加
作成したコマンドに下記のフラグを追加していきます。

- --connected-org：　Salesforce組織を指定する
- --date-range：　ログを取得する日を文字列で指定する
- --output-dir：　出力するフォルダを指定する

フラグは`dev generate flag`コマンドで追加できます。
このコマンドを実行することで`src/commands/elf/export.ts`が上書きされます。
フラグ種別に`requiredOrg`を選択することで簡単にSalesforceに接続させることができます。

```console
$ sf dev generate flag
```

![sf-plugin003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/3d5ed850-df9b-4e88-13fb-7e206f3db85d.png)
![sf-plugin004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/d740c03d-9a97-268e-91a6-7446bf098505.png)
![sf-plugin005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/d36ee7c4-0222-66e7-fed2-87155ef55298.png)

`sf dev generate flag`でフラグを作成すると自動的にtsファイルに下記のような内容が追加されます。

```typescript
  public static readonly flags = {
    name: Flags.string({
      summary: messages.getMessage('flags.name.summary'),
      char: 'n',
      required: false,
    }),
    'connected-org': Flags.requiredOrg({
      summary: messages.getMessage('flags.connected-org.summary'),
      char: 'o',
      required: true,
    }),
    'date-range': Flags.string({
      summary: messages.getMessage('flags.date-range.summary'),
      char: 'r',
      default: 'Last_n_Days:2'
    }),
    'output-dir': Flags.directory({
      summary: messages.getMessage('flags.output-dir.summary'),
      char: 'd',
      required: true
    }),
  };
```

## 4. 処理の実装
### Salesforce組織に接続する
`Flags.requiredOrg`のフラグを作成している場合、下記のように簡単にSalesforce組織に接続できます。

```typescript
const { flags } = await this.parse(ElfExport);
const conn = flags['connected-org'].getConnection();
```

この`getConnection`の戻り値はjsforceの[connection](http://jsforce.github.io/jsforce/doc/Connection.html)を継承しているクラスのインスタンスなので、jsforceと同じメソッドが使用できます。

```typescript:query
const dateRange = flags['date-range'];
const query = `SELECT Id, EventType, LogDate FROM EventLogFile WHERE LogDate = ${dateRange}`;
const result = await conn.query<{ Id: string, EventType: string, LogDate: string }>(query);
```

```typescript:sobject
const record = result.records[0];
const contentStream = conn.sobject('EventLogFile').record(record.Id).blob('LogFile');
```

### （サンプル）イベントモニタリング出力スクリプト
```typescript
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import * as fs from 'fs'

Messages.importMessagesDirectory(__dirname);
const messages = Messages.load('elf-downloader', 'elf.export', [
  'summary',
  'description',
  'examples',
  'flags.name.summary',
  'flags.connected-org.summary',
  'flags.date-range.summary',
  'flags.output-dir.summary',
]);

export type ElfExportResult = {
  length: number;
};

export default class ElfExport extends SfCommand<ElfExportResult> {
  public static readonly summary = messages.getMessage('summary');
  public static readonly description = messages.getMessage('description');
  public static readonly examples = messages.getMessages('examples');

  public static readonly flags = {
    name: Flags.string({
      summary: messages.getMessage('flags.name.summary'),
      char: 'n',
      required: false,
    }),
    'connected-org': Flags.requiredOrg({
      summary: messages.getMessage('flags.connected-org.summary'),
      char: 'o',
      required: true,
    }),
    'date-range': Flags.string({
      summary: messages.getMessage('flags.date-range.summary'),
      char: 'r',
      default: 'Last_n_Days:2'
    }),
    'output-dir': Flags.directory({
      summary: messages.getMessage('flags.output-dir.summary'),
      char: 'd',
      required: true
    }),
  };

  public async run(): Promise<ElfExportResult> {
    const { flags } = await this.parse(ElfExport);

    const dateRange = flags['date-range'];

    const query = `SELECT Id, EventType, LogDate FROM EventLogFile WHERE LogDate = ${dateRange}`;
    const conn = flags['connected-org'].getConnection();
    const result = await conn.query<{ Id: string, EventType: string, LogDate: string }>(query);

    const dir = flags['output-dir'];

    if (result.records.length > 0) {
      for (const record of result.records) {
        const type = record.EventType;
        const date = record.LogDate.substring(0, 10);
        const fileName = `${date}-${type}.csv`;

        this.spinner.start(`Downloading: ${fileName} to ${dir}`);

        const contentStream = conn.sobject('EventLogFile').record(record.Id).blob('LogFile');
        const chunks = [];
        contentStream.on('data', (chunk)=>{
          chunks.push(chunk);
        });

        contentStream.on("end", ()=>{
          const body = Buffer.concat(chunks);
          fs.mkdirSync(dir, {recursive: true});
          fs.writeFileSync(`${dir}/${fileName}`, body);
        });

        this.spinner.stop();
      }
    }
    return {length: result.records.length};
  }
}
```
## 5. コンパイル
下記のコマンドでコンパイルを実施します。

```console
$ yarn compile
```

## 6. Salesforce CLIにリンク
下記のコマンドでSalesforce CLIにリンクできます。

```console
$ sf plugins link .
```

リンクを行ったら下記のコマンドで実行できます。

```console
$ sf elf export --connected-org org-alias --date-range Last_n_Days:2 --output-dir output
```

# 参考資料
- [sf Plugin Developer Guide](https://github.com/salesforcecli/cli/wiki)
- [Building a Salesforce CLI Plug-In | Salesforce Developers Blog](https://developer.salesforce.com/blogs/2023/01/building-a-salesforce-cli-plug-in)
- [Building a CLI Application with oclif | Salesforce Developers Blog](https://developer.salesforce.com/blogs/2022/10/building-a-cli-application-with-oclif)