@yhonda/gcloud-secrets 2.0.11 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @yhonda/gcloud-secrets-mcp
1
+ # @yhonda/gcloud-secrets
2
2
 
3
3
  複数の GCP プロジェクトの `.env` / `.dev.vars` を1つの Secret Manager で一元管理する CLI ツール。
4
4
 
@@ -8,11 +8,14 @@ Claude Code のスキルとしても利用可能。
8
8
 
9
9
  ```
10
10
  Secret Manager (中央プロジェクト)
11
- ├── project-a/
11
+ ├── project-a/ [dev]
12
12
  │ ├── DATABASE_URL
13
13
  │ ├── API_KEY
14
14
  │ └── CLOUDFLARE_SECRET
15
- ├── project-b/
15
+ ├── project-a/ [prod]
16
+ │ ├── DATABASE_URL
17
+ │ └── API_KEY
18
+ ├── project-b/ [dev]
16
19
  │ ├── DATABASE_URL
17
20
  │ └── STRIPE_KEY
18
21
  └── project-c/
@@ -22,13 +25,7 @@ Secret Manager (中央プロジェクト)
22
25
  ## インストール
23
26
 
24
27
  ```bash
25
- # ~/bin にインストール
26
- mkdir -p ~/bin && cd ~/bin
27
- npm install @yhonda/gcloud-secrets-mcp
28
- ln -sf ~/bin/node_modules/.bin/gcloud-secrets-mcp ~/bin/gcloud-secrets-mcp
29
-
30
- # PATH に追加 (~/.bashrc または ~/.zshrc)
31
- echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
28
+ npm install -g @yhonda/gcloud-secrets
32
29
  ```
33
30
 
34
31
  ### 前提条件
@@ -39,58 +36,100 @@ echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
39
36
  ## 初期設定
40
37
 
41
38
  ```bash
42
- gcloud-secrets-mcp init <project-id>
39
+ gcloud-secrets init <project-id> [--env <default-env>]
43
40
  ```
44
41
 
45
42
  設定は `~/.secrets-manager.conf` に保存されます。
46
43
 
47
- ## CLI 使い方
44
+ ## CLI コマンド
45
+
46
+ ### 基本操作
48
47
 
49
48
  ```bash
50
- # フォルダ一覧
51
- gcloud-secrets-mcp list
49
+ # フォルダ一覧(環境ごとにグループ化)
50
+ gcloud-secrets list
52
51
 
53
52
  # フォルダ内のシークレット一覧
54
- gcloud-secrets-mcp list my-project
53
+ gcloud-secrets list my-project --env dev
55
54
 
56
- # シークレットを取得(.env形式で標準出力)
57
- gcloud-secrets-mcp pull my-project
55
+ # シークレットを取得(.env 形式で標準出力)
56
+ gcloud-secrets pull my-project --env prod
58
57
 
59
58
  # シークレットをアップロード
60
- gcloud-secrets-mcp push my-project .env
59
+ gcloud-secrets push my-project .env --env dev
61
60
  ```
62
61
 
63
- ## Claude Code スキル
62
+ ### スキャン & 検索
64
63
 
65
- `~/.claude/commands/secrets.md` を作成すると `/secrets` コマンドが使えます:
64
+ ```bash
65
+ # 全リポジトリの .env 同期状況をスキャン
66
+ gcloud-secrets scan
67
+
68
+ # 指定パス以下をスキャン(特定環境のみ)
69
+ gcloud-secrets scan ~/projects --env dev
66
70
 
67
- ```markdown
68
- # GCP Secret Manager スキル
71
+ # 値から逆引き検索
72
+ gcloud-secrets search "api-key-12345"
73
+ ```
69
74
 
70
- ユーザーの指示に従って以下のコマンドを実行:
75
+ ### 自動同期 (pre-commit hook)
71
76
 
72
- - `~/bin/gcloud-secrets-mcp list` - フォルダ一覧
73
- - `~/bin/gcloud-secrets-mcp list <folder>` - シークレット一覧
74
- - `~/bin/gcloud-secrets-mcp pull <folder>` - 取得
75
- - `~/bin/gcloud-secrets-mcp push <folder> <file>` - アップロード
77
+ ```bash
78
+ # グローバル git hook をインストール(全リポジトリ対象)
79
+ gcloud-secrets hook install
80
+
81
+ # アンインストール
82
+ gcloud-secrets hook uninstall
83
+
84
+ # 手動実行
85
+ gcloud-secrets pre-commit
76
86
  ```
77
87
 
78
- ### 使用例
88
+ `hook install` すると、全リポジトリで `git commit` のたびに `.env` が自動で Secret Manager に同期されます。
79
89
 
80
- Claude に以下のように依頼できます:
90
+ **高速化の仕組み:**
91
+ - キャッシュ (`~/.secrets-manager-cache.json`) で .env の変更を検知
92
+ - 変更なし → **0 API コール**(即座に終了)
93
+ - 変更あり → フィルタ付き API + 並列取得で高速チェック&自動 push
94
+ - 常に exit 0(commit をブロックしない)
95
+ - 既存の `.husky/` や `.git/hooks/` と互換性あり
81
96
 
82
- - `/secrets list`
83
- - `/secrets pull my-project`
84
- - 「このプロジェクトの .env Secret Manager にアップロードして」
97
+ ## 環境 (Environment)
98
+
99
+ `--env` または `-e` で環境を指定できます:
100
+
101
+ ```bash
102
+ gcloud-secrets push --env dev # dev 環境にアップロード
103
+ gcloud-secrets pull -e prod # prod 環境から取得
104
+ gcloud-secrets scan --env staging # staging のみスキャン
105
+ ```
106
+
107
+ デフォルト環境は `~/.secrets-manager.conf` の `DEFAULT_ENVIRONMENT` で設定。
85
108
 
86
109
  ## コマンド一覧
87
110
 
88
111
  | コマンド | 説明 |
89
112
  |---------|------|
90
- | `init <project-id>` | 中央プロジェクトを設定 |
91
- | `list [folder]` | 一覧表示 |
92
- | `pull [folder]` | シークレットを取得 |
93
- | `push [folder] [file]` | アップロード |
113
+ | `init <project-id> [--env <default>]` | 中央プロジェクトを設定 |
114
+ | `list [folder] [--env <env>]` | 一覧表示 |
115
+ | `pull [folder] [--env <env>]` | シークレットを取得 |
116
+ | `push [folder] [file] [--env <env>]` | アップロード |
117
+ | `scan [basePath] [--env <env>]` | Git リポジトリの同期状況をスキャン |
118
+ | `search <keyword> [--env <env>]` | 値から逆引き検索 |
119
+ | `pre-commit` | .env 自動同期(git hook 用) |
120
+ | `hook install` | グローバル git hook をインストール |
121
+ | `hook uninstall` | グローバル git hook をアンインストール |
122
+
123
+ ## フォルダ名の正規化
124
+
125
+ ディレクトリ名は自動で kebab-case に変換されます:
126
+
127
+ - `gcloudSec` → `gcloud-sec`
128
+ - `myAppTest` → `my-app-test`
129
+
130
+ ## シークレット名の形式
131
+
132
+ `{folder}_{env}_{KEY}` (例: `gcloud-sec_dev_DATABASE_URL`)
94
133
 
95
134
  ## 設定
96
135
 
@@ -102,8 +141,17 @@ export SECRETS_CENTRAL_PROJECT=your-project-id
102
141
 
103
142
  # または設定ファイル (~/.secrets-manager.conf)
104
143
  SECRETS_CENTRAL_PROJECT=your-project-id
144
+ DEFAULT_ENVIRONMENT=dev
105
145
  ```
106
146
 
147
+ ## Claude Code スキル
148
+
149
+ インストール時に `~/.claude/skills/secrets.md` が自動作成され、`/secrets` コマンドが使えます:
150
+
151
+ - 「このプロジェクトの .env を Secret Manager にアップロードして」
152
+ - 「dev 環境のシークレットを確認して」
153
+ - 「全リポジトリの同期状況をスキャンして」
154
+
107
155
  ## ライセンス
108
156
 
109
157
  MIT
package/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
4
- import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync } from "fs";
4
+ import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, mkdirSync, chmodSync, rmSync } from "fs";
5
+ import { createHash } from "crypto";
5
6
  import { basename, join, dirname, resolve } from "path";
6
7
  import { homedir } from "os";
7
8
  import { execSync } from "child_process";
@@ -146,6 +147,27 @@ function compareValues(a, b) {
146
147
  return a.trim().replace(/\r\n/g, '\n') === b.trim().replace(/\r\n/g, '\n');
147
148
  }
148
149
 
150
+ // キャッシュ管理
151
+ function getCachePath() {
152
+ return join(homedir(), '.secrets-manager-cache.json');
153
+ }
154
+
155
+ function readCache() {
156
+ const cachePath = getCachePath();
157
+ if (existsSync(cachePath)) {
158
+ try { return JSON.parse(readFileSync(cachePath, 'utf-8')); } catch { return {}; }
159
+ }
160
+ return {};
161
+ }
162
+
163
+ function writeCache(cache) {
164
+ writeFileSync(getCachePath(), JSON.stringify(cache, null, 2));
165
+ }
166
+
167
+ function hashContent(content) {
168
+ return createHash('md5').update(content).digest('hex');
169
+ }
170
+
149
171
  // CLI モード
150
172
  async function runCli(args) {
151
173
  const parsed = parseArgs(args);
@@ -153,7 +175,7 @@ async function runCli(args) {
153
175
  const config = getConfig();
154
176
  const targetEnv = parsed.env || config.defaultEnvironment;
155
177
 
156
- if (!config.centralProject && command !== "init") {
178
+ if (!config.centralProject && command !== "init" && command !== "pre-commit" && command !== "hook") {
157
179
  console.error("エラー: 先に init を実行してください");
158
180
  process.exit(1);
159
181
  }
@@ -408,6 +430,128 @@ async function runCli(args) {
408
430
  break;
409
431
  }
410
432
 
433
+ case "pre-commit": {
434
+ // config なし → サイレント exit
435
+ if (!config.centralProject) process.exit(0);
436
+
437
+ const cwd = process.cwd();
438
+ const folder = normalizeFolder(basename(resolve(cwd)));
439
+ const envFiles = findEnvFiles(cwd);
440
+ if (envFiles.length === 0) process.exit(0);
441
+
442
+ const cache = readCache();
443
+ const parent = `projects/${config.centralProject}`;
444
+ let totalPushed = 0;
445
+
446
+ for (const envFile of envFiles) {
447
+ let content;
448
+ try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
449
+ if (!content.trim()) continue;
450
+
451
+ const currentHash = hashContent(content);
452
+ const cacheKey = envFile.path;
453
+
454
+ // キャッシュヒット → スキップ (0 API コール)
455
+ if (cache[cacheKey] && cache[cacheKey].hash === currentHash) {
456
+ console.log(`✓ ${envFile.filename} synced`);
457
+ continue;
458
+ }
459
+
460
+ const localEntries = parseEnvFile(content);
461
+ if (localEntries.length === 0) continue;
462
+
463
+ const labels = { folder, environment: targetEnv };
464
+
465
+ try {
466
+ // フィルタ付き listSecrets (1 API コール、ラベル込み)
467
+ const filter = `labels.folder=${folder} AND labels.environment=${targetEnv}`;
468
+ const [remoteSecrets] = await client.listSecrets({ parent, filter });
469
+
470
+ // リモートキー → シークレット名マップ
471
+ const remoteMap = new Map();
472
+ for (const secret of remoteSecrets) {
473
+ const { key } = getKeyFromSecret(secret.name.split('/').pop(), folder);
474
+ remoteMap.set(key, secret.name);
475
+ }
476
+
477
+ // リモート値を並列取得
478
+ const remoteValues = new Map();
479
+ const keysToCompare = localEntries.filter(e => remoteMap.has(e.key));
480
+ if (keysToCompare.length > 0) {
481
+ const results = await Promise.all(
482
+ keysToCompare.map(async (entry) => {
483
+ try {
484
+ const [version] = await client.accessSecretVersion({
485
+ name: `${remoteMap.get(entry.key)}/versions/latest`,
486
+ });
487
+ return { key: entry.key, value: version.payload.data.toString('utf-8') };
488
+ } catch { return { key: entry.key, value: null }; }
489
+ })
490
+ );
491
+ for (const r of results) {
492
+ if (r.value !== null) remoteValues.set(r.key, r.value);
493
+ }
494
+ }
495
+
496
+ // 新規/変更キーを特定
497
+ const toPush = [];
498
+ for (const entry of localEntries) {
499
+ if (!remoteMap.has(entry.key)) {
500
+ toPush.push(entry);
501
+ } else if (!remoteValues.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key))) {
502
+ toPush.push(entry);
503
+ }
504
+ }
505
+
506
+ // push
507
+ if (toPush.length > 0) {
508
+ for (const entry of toPush) {
509
+ const secretId = makeSecretName(folder, entry.key, targetEnv);
510
+ const secretName = `${parent}/secrets/${secretId}`;
511
+ try {
512
+ await client.getSecret({ name: secretName });
513
+ await client.updateSecret({
514
+ secret: { name: secretName, labels },
515
+ updateMask: { paths: ['labels'] }
516
+ });
517
+ await client.addSecretVersion({
518
+ parent: secretName,
519
+ payload: { data: Buffer.from(entry.value) },
520
+ });
521
+ } catch {
522
+ await client.createSecret({
523
+ parent,
524
+ secretId,
525
+ secret: { replication: { automatic: {} }, labels },
526
+ });
527
+ await client.addSecretVersion({
528
+ parent: secretName,
529
+ payload: { data: Buffer.from(entry.value) },
530
+ });
531
+ }
532
+ }
533
+ console.log(`↑ ${envFile.filename}: ${toPush.length} secrets pushed (${folder}/${targetEnv})`);
534
+ totalPushed += toPush.length;
535
+ } else {
536
+ console.log(`✓ ${envFile.filename} synced`);
537
+ }
538
+
539
+ // キャッシュ更新
540
+ cache[cacheKey] = {
541
+ hash: currentHash,
542
+ folder,
543
+ env: targetEnv,
544
+ syncedAt: new Date().toISOString()
545
+ };
546
+ } catch (error) {
547
+ console.log(`⚠ ${envFile.filename}: sync skipped (${error.message})`);
548
+ }
549
+ }
550
+
551
+ try { writeCache(cache); } catch { }
552
+ process.exit(0);
553
+ }
554
+
411
555
  case "search": {
412
556
  const keyword = parsed.positional[1];
413
557
  if (!keyword) {
@@ -463,6 +607,88 @@ async function runCli(args) {
463
607
  break;
464
608
  }
465
609
 
610
+ case "hook": {
611
+ const subcommand = parsed.positional[1];
612
+
613
+ if (subcommand === "install") {
614
+ const hooksDir = join(homedir(), '.git-hooks');
615
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
616
+
617
+ const hookTypes = [
618
+ 'applypatch-msg', 'pre-applypatch', 'post-applypatch',
619
+ 'pre-commit', 'prepare-commit-msg', 'commit-msg', 'post-commit',
620
+ 'pre-rebase', 'post-checkout', 'post-merge',
621
+ 'pre-push', 'pre-auto-gc', 'post-rewrite'
622
+ ];
623
+
624
+ for (const hookType of hookTypes) {
625
+ const hookPath = join(hooksDir, hookType);
626
+ let extraLogic = '';
627
+
628
+ if (hookType === 'pre-commit') {
629
+ extraLogic = `
630
+ # gcloud-secrets: auto-sync .env to Secret Manager
631
+ if command -v gcloud-secrets >/dev/null 2>&1; then
632
+ gcloud-secrets pre-commit
633
+ fi
634
+ `;
635
+ }
636
+
637
+ const hookScript = `#!/bin/sh
638
+ # Global git hook: ${hookType}
639
+ # Installed by gcloud-secrets
640
+ ${extraLogic}
641
+ # Forward to .husky/${hookType} if it exists
642
+ if [ -f "$(pwd)/.husky/${hookType}" ]; then
643
+ "$(pwd)/.husky/${hookType}" "$@"
644
+ exit_code=$?
645
+ if [ $exit_code -ne 0 ]; then
646
+ exit $exit_code
647
+ fi
648
+ fi
649
+
650
+ # Forward to .git/hooks/${hookType} if it exists
651
+ GIT_DIR_HOOKS="$(git rev-parse --git-dir 2>/dev/null)/hooks/${hookType}"
652
+ if [ -f "$GIT_DIR_HOOKS" ] && [ -x "$GIT_DIR_HOOKS" ]; then
653
+ "$GIT_DIR_HOOKS" "$@"
654
+ exit $?
655
+ fi
656
+
657
+ exit 0
658
+ `;
659
+ writeFileSync(hookPath, hookScript);
660
+ chmodSync(hookPath, '755');
661
+ }
662
+
663
+ execSync('git config --global core.hooksPath ~/.git-hooks');
664
+ console.log(`グローバル git hooks をインストールしました:
665
+ フックディレクトリ: ${hooksDir}
666
+ 対象: pre-commit (gcloud-secrets auto-sync)
667
+ 互換性: .husky/ と .git/hooks/ にフォワード
668
+
669
+ 全リポジトリの git commit で .env が自動同期されます。`);
670
+
671
+ } else if (subcommand === "uninstall") {
672
+ try { execSync('git config --global --unset core.hooksPath', { stdio: 'ignore' }); } catch { }
673
+ const hooksDir = join(homedir(), '.git-hooks');
674
+ if (existsSync(hooksDir)) {
675
+ try {
676
+ rmSync(hooksDir, { recursive: true, force: true });
677
+ } catch (error) {
678
+ console.log(`⚠ ${hooksDir} の削除に失敗: ${error.message}`);
679
+ console.log(`手動で削除してください: rm -rf ${hooksDir}`);
680
+ }
681
+ }
682
+ console.log(`グローバル git hooks をアンインストールしました。`);
683
+
684
+ } else {
685
+ console.log(`使い方:
686
+ gcloud-secrets hook install グローバル pre-commit hook をインストール
687
+ gcloud-secrets hook uninstall グローバル pre-commit hook をアンインストール`);
688
+ }
689
+ break;
690
+ }
691
+
466
692
  default:
467
693
  console.log(`gcloud-secrets - GCP Secret Manager CLI
468
694
 
@@ -473,6 +699,9 @@ async function runCli(args) {
473
699
  gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
474
700
  gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
475
701
  gcloud-secrets search <keyword> [--env <env>] 値から逆引き検索
702
+ gcloud-secrets pre-commit .env 自動同期 (git hook 用)
703
+ gcloud-secrets hook install グローバル git hook インストール
704
+ gcloud-secrets hook uninstall グローバル git hook アンインストール
476
705
 
477
706
  オプション:
478
707
  --env, -e <env> 環境を指定 (dev, staging, prod など)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhonda/gcloud-secrets",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "GCP Secret Manager を GitHub clone 風に管理する CLI ツール",
5
5
  "type": "module",
6
6
  "main": "cli.js",
package/skills/secrets.md CHANGED
@@ -92,6 +92,25 @@ Found 3 matches in 2 folders
92
92
  未登録: 1
93
93
  ```
94
94
 
95
+ ### .env 自動同期 (pre-commit)
96
+ ```bash
97
+ # カレントディレクトリの .env を Secret Manager に自動同期
98
+ gcloud-secrets pre-commit
99
+ ```
100
+ git hook 用の高速コマンド。キャッシュで .env の変更を検知し、変更がなければ API コール 0 で即座に終了。
101
+ 変更があれば `listSecrets` のフィルタ + 並列取得で高速にチェックし、新規/差分のある secret を自動 push。
102
+
103
+ ### グローバル git hook (hook)
104
+ ```bash
105
+ # グローバル pre-commit hook をインストール
106
+ gcloud-secrets hook install
107
+
108
+ # アンインストール
109
+ gcloud-secrets hook uninstall
110
+ ```
111
+ `hook install` で全リポジトリの `git commit` 時に `pre-commit` が自動実行されます。
112
+ 既存の `.husky/` や `.git/hooks/` のフックにもフォワードするので互換性があります。
113
+
95
114
  ## 環境 (Environment) オプション
96
115
 
97
116
  `--env` または `-e` で環境を指定できます:
@@ -122,4 +141,10 @@ gcloud-secrets scan ~/ --env dev
122
141
 
123
142
  # 6. 特定の値がどこで使われているか検索
124
143
  gcloud-secrets search "line-client-id-xxx"
144
+
145
+ # 7. グローバル git hook をインストール (全リポジトリで自動同期)
146
+ gcloud-secrets hook install
147
+
148
+ # 8. 手動で pre-commit を実行
149
+ gcloud-secrets pre-commit
125
150
  ```