Read OSS

設定・プロファイル・CLI:Hyperのカスタマイズの仕組み

中級

前提知識

  • 第1回:アーキテクチャとプロジェクト構造
  • 第5回:プラグインシステム詳解
  • XDG Base Directory仕様の基本的な理解

設定・プロファイル・CLI:Hyperのカスタマイズの仕組み

このシリーズを通じて、設定データがほぼすべてのサブシステムを流れていく様子を見てきました。メインプロセスが読み込み、プラグインシステムがデコレートし、レンダラーがxterm.jsに適用し、RPCブリッジがプロセス間の更新を仲介します。これまでそれを「ブラックボックス」として扱ってきました。シリーズ最終回となる今回は、いよいよそのボックスの中を開きます。hyper.jsonの構造と多段階マージパイプラインによる設定の組み立て、プロファイルシステムによるコンテキスト別のオーバーライドを見ていきます。さらに、独立したCLIツールがconfig fileを直接編集してプラグインを管理する仕組みも解説します。

Config Fileの形式とファイルシステムのレイアウト

Hyperの設定は、4つのトップレベルキーを持つ単一のJSONファイルに集約されています。

app/config/config-default.json

{
  "$schema": "./schema.json",
  "config": { /* terminal settings */ },
  "plugins": [],
  "localPlugins": [],
  "keymaps": {}
}

configキーにはフォント、カラー、シェルパス、スクロールバック、カーソルスタイルといったターミナル設定が収まります。plugins配列にはインストールするnpmパッケージ名を列挙し、localPluginsはローカルプラグインフォルダ内のディレクトリを参照します。keymapsはキーボードショートカットのオーバーライドに使います。

ファイルシステム上のレイアウトはapp/config/paths.tsで定義されています。

パス プラットフォーム 場所
Config dir macOS/Linux ~/.config/Hyper/(または$XDG_CONFIG_HOME/Hyper/
Config dir Windows %APPDATA%/Hyper/
Config file 全プラットフォーム <config dir>/hyper.json
Plugins dir 全プラットフォーム <config dir>/plugins/
Local plugins 全プラットフォーム <config dir>/plugins/local/
Plugin cache 全プラットフォーム <config dir>/plugins/cache/
Dev override 開発モード <repo root>/hyper.json

app/config/paths.ts#L17-L21

XDGサポートも注目ポイントです。$XDG_CONFIG_HOMEが設定されていればHyperはそれを優先します。開発モードでは、リポジトリルートにhyper.jsonが存在する場合、config pathがそちらに上書きされます。これにより、グローバル設定を変更せずに設定の動作確認ができます。

ヒント: app/config/schema.jsonは、typescript-json-schema ./typings/config.d.ts rawConfigコマンドによってTypeScript型定義から自動生成されています。デフォルトconfigにある"$schema"の参照は、VS Codeなどのスキーマ対応エディタでのオートコンプリートを有効にします。

Configの読み込みとマージパイプライン

config読み込みは_import()から始まり、複数のステージを経て処理されます。

app/config/import.ts

flowchart TD
    A["Read config-default.json"] --> B["Read platform keymaps\n(darwin.json / win32.json / linux.json)"]
    B --> C["Read user hyper.json"]
    C --> D["Attempt legacy migration\n(.hyper.js → hyper.json)"]
    D --> E["_init(): Deep merge\ndefaults + user config"]
    E --> F["Normalize profiles\n(ensure default profile exists)"]
    F --> G["Merge platform keymaps\nwith user keymaps"]
    G --> H["Filter null/undefined\nfrom plugin arrays"]
    H --> I["parsedConfig object"]

マージ処理は_init関数が担当します。

app/config/init.ts#L33-L61

ディープマージにはLodashのmerge()を使用しており、オブジェクトは再帰的にマージされる一方、配列は完全に置き換えられます。プロファイルの正規化では、設定に必ず「default」という名前のプロファイルが空のconfigオブジェクトとともに存在するよう保証します。

keymapsのマージはよりシンプルで、スプレッド構文によりユーザー設定がプラットフォームのデフォルトを上書きします:{...defaultCfg.keymaps, ...userCfg?.keymaps}。その後、mapKeysがキーの組み合わせを正規化します(配列への変換や非推奨フォーマットの処理などを含みます)。

エラー処理も堅牢です。hyper.jsonのパースに失敗した場合はデフォルト設定が使われ、通知が表示されます。デフォルトファイル自体が見つからない場合は、空オブジェクトをフォールバックとして使用します。

プロファイルシステム

プロファイルを使うと、用途ごとに異なるシェル、カラー、フォントといった設定をオーバーライドできます。プロファイルの解決はapp/config.ts#L97-L108で行われます。

export const getProfileConfig = (profileName: string): configOptions => {
  const {profiles, defaultProfile, ...baseConfig} = cfg.config;
  const profileConfig = profiles.find((p) => p.name === profileName)?.config || {};
  for (const key in profileConfig) {
    if (typeof baseConfig[key] === 'object' && !Array.isArray(baseConfig[key])) {
      baseConfig[key] = {...baseConfig[key], ...profileConfig[key]};
    } else {
      baseConfig[key] = profileConfig[key];
    }
  }
  return {...baseConfig, defaultProfile, profiles};
};
flowchart LR
    A["Base Config\n(all keys except profiles)"] --> D["Merge Strategy"]
    B["Profile Config\n(profile-specific overrides)"] --> D
    D --> E{"Value type?"}
    E -->|"Object (not Array)"| F["Shallow merge\n{...base, ...profile}"]
    E -->|"Scalar or Array"| G["Replace entirely"]
    F --> H["Final Config"]
    G --> H

マージ戦略はオブジェクトに対して意図的に シャロー です。たとえばプロファイルのcolorsオーバーライドはベースのcolorsとシャローマージされるため、変更したい色だけを指定すれば済みます。ただしshellArgsのような配列は結合ではなく完全置換になるため、注意が必要です。

プロファイル固有のコマンドはapp/commands.ts#L150-L163で自動生成されます。

getConfig().profiles.forEach((profile) => {
  commands[`window:new:${profile.name}`] = () => {
    setTimeout(() => app.createWindow(undefined, undefined, profile.name), 0);
  };
  commands[`tab:new:${profile.name}`] = (focusedWindow) => {
    focusedWindow?.rpc.emit('termgroup add req', {profile: profile.name});
  };
  // ... pane:splitRight, pane:splitDown
});

各プロファイルは自動的に、そのプロファイルの設定で新しいウィンドウ・タブ・スプリットペインを開くコマンドを持ちます。これらはkeymaps設定でキーボードショートカットに割り当てることができます。

Configの監視と変更の伝播

設定の変更はchokidarによるファイル監視で検知されます。

app/config.ts#L40-L70

ウォッチャーはsetTimeoutによる100msのデバウンスを設けており、ファイルへの書き込みが完了してから読み込みを行います。変更が検知されると、その影響はシステム全体に連鎖して伝播します。

sequenceDiagram
    participant FS as hyper.json
    participant CW as Chokidar Watcher
    participant CF as Config Module
    participant P as Plugin System
    participant W as BrowserWindow
    participant R as Renderer

    FS->>CW: File changed
    CW->>CF: onChange callback (100ms debounce)
    CF->>CF: Re-import and parse config
    CF->>CF: Notify all subscribers

    Note over CF: Subscriber 1: Menu rebuild
    CF->>W: Menu.setApplicationMenu(newMenu)

    Note over CF: Subscriber 2: Plugin check
    CF->>P: Compare JSON.stringify(plugins)
    P->>P: If changed → updatePlugins()

    Note over CF: Subscriber 3: Window update
    CF->>W: webContents.send('config change')
    W->>R: Renderer receives config change
    R->>R: config.subscribe callback
    R->>R: store.dispatch(reloadConfig(newConfig))
    R->>R: React re-renders with new config

サブスクライバーのパターンはシンプルで、コールバックの配列とsubscribe関数で構成されています。subscribeは登録解除用の関数を返します。

export const subscribe = (fn: Function) => {
  watchers.push(fn);
  return () => {
    watchers.splice(watchers.indexOf(fn), 1);
  };
};

BrowserWindowapp/ui/window.ts#L78-L93でconfig変更を購読します。レンダラーに'config change'イベントを送るとともに、シェルの設定が変わったかどうかもチェックします。変更されていた場合は、新しいシェルを使うには新しいタブを開く必要があることをユーザーに通知します。

レガシーマイグレーションとスキーマ生成

Hyper 3ではmodule.exportsを使ったJavaScriptのconfig file(.hyper.js)が使われていました。Hyper 4でJSON(hyper.json)に移行し、app/config/migrate.tsがこの移行を処理します。

app/config/migrate.ts#L147-L190

マイグレーションはhyper.jsonが存在せず.hyper.jsがある場合にのみ実行されます。処理の流れは次のとおりです。

  1. .hyper_plugins/localからローカルプラグインを新しい場所にコピー
  2. vm.ScriptでJS configをパースし、エクスポートされたオブジェクトを取り出す
  3. デフォルトとディープマージして完全なJSON configを生成
  4. JSON化できない設定(計算された値や関数)を、recastによるAST変換でmigrated-hyper3-config.jsというローカルプラグインに抽出
  5. 新しいhyper.jsonを書き出す

特に巧妙なのがconfigToPlugin関数です。AST操作によって旧JS configからJSON非シリアライズな式を取り出し、decorateConfigプラグインとしてラップします。これにより、移行後もその設定が透過的に機能し続けます。

flowchart TD
    A[".hyper.js exists\nhyper.json missing"] --> B["Parse JS with vm.Script"]
    B --> C["Extract module.exports"]
    C --> D["Deep merge with defaults"]
    D --> E["Write hyper.json"]
    C --> F["AST-analyze for non-JSON values"]
    F --> G{"Has computed/function values?"}
    G -->|Yes| H["Generate migrated-hyper3-config.js\nas local plugin"]
    G -->|No| I["Done"]
    H --> J["Add to localPlugins array"]
    J --> I

CLIツール:プラグイン管理

cli/index.tsはElectronアプリに同梱されるスタンドアロンのNode.jsツールです。hyper.jsonを直接読み書きしてプラグインを管理するため、実行中のアプリとのIPC通信は行いません。

CLIは6つのサブコマンドを提供します。

cli/index.ts#L55-L186

コマンド エイリアス 動作
hyper install <plugin> i npmで存在を確認してplugins配列に追加
hyper uninstall <plugin> u, rm, remove plugins配列から削除
hyper list ls インストール済みプラグインを表示
hyper search <query> s npms.ioでhyper-plugin/hyper-themeパッケージを検索
hyper list-remote lsr, ls-remote 利用可能な全プラグインを一覧表示
hyper docs <plugin> d, h, home プラグインのnpmページを開く

cli/api.ts#L100-L119のinstallフローでは、configに追加する前にnpm上でパッケージの存在を確認します。

function install(plugin, locally?) {
  return existsOnNpm(plugin)
    .catch((err) => {
      if (statusCode === 404 || statusCode === 200) {
        return Promise.reject(`${plugin} not found on npm`);
      }
      return Promise.reject(`Plugin check failed...`);
    })
    .then(() => {
      if (isInstalled(plugin, locally)) {
        return Promise.reject(`${plugin} is already installed`);
      }
      const config = getParsedFile();
      config[locally ? 'localPlugins' : 'plugins'] = [...array, plugin];
      save(config);
    });
}

サブコマンドが指定されない場合、CLIはHyper自体を起動します。macOSではopen -b co.zeit.hyperを使って複数インスタンスの起動を防ぎます。それ以外のプラットフォームでは、Electronをデタッチされた子プロセスとして起動します。

cli/index.ts#L192-L258

起動時に環境変数HYPER_CLI=1が設定され、CLIから起動されたことをElectronアプリに伝えます。またELECTRON_NO_ATTACH_CONSOLE=1により、ElectronプロセスがCLIのコンソールを引き継がないようにして、ターミナルの表示をクリーンに保ちます。

ヒント: CLIはメモ化(memoization)を使ってconfig fileを遅延読み込みします(cli/api.tsを参照)。そのためdocsversionのようにconfigを必要としないサブコマンドは、config fileが存在しない場合や壊れている場合でも正常に動作します。

シリーズのまとめ

6回の記事を通じて、Hyperのアーキテクチャ全体を追いかけてきました。3プロセス設計とwebpackビルドツール、型付きRPCブリッジとターミナルセッションのライフサイクル、ReactをバイパスするRedux middlewareチェーンを見てきました。さらに、WebGLフォールバックを備えたxterm.jsコンポーネントのラッパー、38のextension pointを持つプラグインシステム、そしてそれらをすべてつなぎ合わせる設定パイプラインも解説しました。

Hyperはある意味で興味深いアーキテクチャの選択を体現しています。パフォーマンスが重要なアプリケーションにWebテクノロジーを採用しながら、その抽象化レイヤーによって生じたパフォーマンスの損失を取り戻すために巧妙な最適化(V8スナップショット、write middleware、DataBatcher、DOM保持)を積み上げているのです。Electronアプリの開発やプラグインシステムの設計に興味がある方にとって、このコードベースに登場するパターンはどれも応用する価値があります。サービスロケーターオブジェクト、UUIDスコープのチャンネル、error-boundaryのデコレーションチェーン、イミュータブルなツリー状態など、自分のプロジェクトに活かせるものばかりです。