@yhonda/gcloud-secrets 2.0.1

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 ADDED
@@ -0,0 +1,109 @@
1
+ # @yhonda/gcloud-secrets-mcp
2
+
3
+ 複数の GCP プロジェクトの `.env` / `.dev.vars` を1つの Secret Manager で一元管理する CLI ツール。
4
+
5
+ Claude Code のスキルとしても利用可能。
6
+
7
+ ## 概要
8
+
9
+ ```
10
+ Secret Manager (中央プロジェクト)
11
+ ├── project-a/
12
+ │ ├── DATABASE_URL
13
+ │ ├── API_KEY
14
+ │ └── CLOUDFLARE_SECRET
15
+ ├── project-b/
16
+ │ ├── DATABASE_URL
17
+ │ └── STRIPE_KEY
18
+ └── project-c/
19
+ └── ...
20
+ ```
21
+
22
+ ## インストール
23
+
24
+ ```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
32
+ ```
33
+
34
+ ### 前提条件
35
+
36
+ - Node.js 18 以上
37
+ - GCP 認証済み(`gcloud auth application-default login`)
38
+
39
+ ## 初期設定
40
+
41
+ ```bash
42
+ gcloud-secrets-mcp init <project-id>
43
+ ```
44
+
45
+ 設定は `~/.secrets-manager.conf` に保存されます。
46
+
47
+ ## CLI 使い方
48
+
49
+ ```bash
50
+ # フォルダ一覧
51
+ gcloud-secrets-mcp list
52
+
53
+ # フォルダ内のシークレット一覧
54
+ gcloud-secrets-mcp list my-project
55
+
56
+ # シークレットを取得(.env形式で標準出力)
57
+ gcloud-secrets-mcp pull my-project
58
+
59
+ # シークレットをアップロード
60
+ gcloud-secrets-mcp push my-project .env
61
+ ```
62
+
63
+ ## Claude Code スキル
64
+
65
+ `~/.claude/commands/secrets.md` を作成すると `/secrets` コマンドが使えます:
66
+
67
+ ```markdown
68
+ # GCP Secret Manager スキル
69
+
70
+ ユーザーの指示に従って以下のコマンドを実行:
71
+
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>` - アップロード
76
+ ```
77
+
78
+ ### 使用例
79
+
80
+ Claude に以下のように依頼できます:
81
+
82
+ - `/secrets list`
83
+ - `/secrets pull my-project`
84
+ - 「このプロジェクトの .env を Secret Manager にアップロードして」
85
+
86
+ ## コマンド一覧
87
+
88
+ | コマンド | 説明 |
89
+ |---------|------|
90
+ | `init <project-id>` | 中央プロジェクトを設定 |
91
+ | `list [folder]` | 一覧表示 |
92
+ | `pull [folder]` | シークレットを取得 |
93
+ | `push [folder] [file]` | アップロード |
94
+
95
+ ## 設定
96
+
97
+ 環境変数または設定ファイルで中央プロジェクトを指定:
98
+
99
+ ```bash
100
+ # 環境変数
101
+ export SECRETS_CENTRAL_PROJECT=your-project-id
102
+
103
+ # または設定ファイル (~/.secrets-manager.conf)
104
+ SECRETS_CENTRAL_PROJECT=your-project-id
105
+ ```
106
+
107
+ ## ライセンス
108
+
109
+ MIT
package/cli.js ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
4
+ import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync } from "fs";
5
+ import { basename, join, dirname, resolve } from "path";
6
+ import { homedir } from "os";
7
+ import { execSync } from "child_process";
8
+
9
+ // SDK クライアント初期化
10
+ const client = new SecretManagerServiceClient();
11
+
12
+ // 設定読み込み
13
+ function getConfig() {
14
+ const configFile = `${homedir()}/.secrets-manager.conf`;
15
+ if (existsSync(configFile)) {
16
+ const content = readFileSync(configFile, "utf-8");
17
+ const match = content.match(/SECRETS_CENTRAL_PROJECT=(.+)/);
18
+ if (match) {
19
+ return { centralProject: match[1].trim() };
20
+ }
21
+ }
22
+ return { centralProject: process.env.SECRETS_CENTRAL_PROJECT || "" };
23
+ }
24
+
25
+ // シークレット名生成
26
+ function makeSecretName(folder, key) {
27
+ return `${folder}_${key}`;
28
+ }
29
+
30
+ // シークレット名からキーを抽出
31
+ function getKeyFromSecret(secretName) {
32
+ const parts = secretName.split("_");
33
+ return parts.slice(1).join("_");
34
+ }
35
+
36
+ // フォルダ名を正規化 (camelCase → kebab-case)
37
+ function normalizeFolder(name) {
38
+ return name
39
+ .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase → kebab-case
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9_-]/g, '-');
42
+ }
43
+
44
+ // Git リポジトリを再帰的に検索
45
+ function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
46
+ const repos = [];
47
+ if (currentDepth > maxDepth) return repos;
48
+
49
+ try {
50
+ const entries = readdirSync(basePath, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ if (entry.name.startsWith('.') && entry.name !== '.git') continue;
54
+ if (entry.name === 'node_modules') continue;
55
+
56
+ const fullPath = join(basePath, entry.name);
57
+ try {
58
+ if (lstatSync(fullPath).isSymbolicLink()) continue;
59
+ } catch { continue; }
60
+
61
+ if (entry.name === '.git') {
62
+ repos.push(dirname(fullPath));
63
+ } else {
64
+ repos.push(...findGitRepositories(fullPath, maxDepth, currentDepth + 1));
65
+ }
66
+ }
67
+ } catch { }
68
+ return repos;
69
+ }
70
+
71
+ // .env ファイルを検索
72
+ function findEnvFiles(repoPath) {
73
+ const envFiles = [];
74
+ for (const filename of ['.env', '.dev.vars', '.env.local', '.env.production']) {
75
+ const filePath = join(repoPath, filename);
76
+ if (existsSync(filePath)) {
77
+ let gitIgnored = false;
78
+ try {
79
+ execSync(`git -C "${repoPath}" check-ignore -q "${filename}"`, { stdio: 'ignore' });
80
+ gitIgnored = true;
81
+ } catch { }
82
+ envFiles.push({ path: filePath, filename, gitIgnored });
83
+ }
84
+ }
85
+ return envFiles;
86
+ }
87
+
88
+ // .env ファイルをパース
89
+ function parseEnvFile(content) {
90
+ const entries = [];
91
+ const multilineRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*`([\s\S]*?)`/gm;
92
+ let remaining = content;
93
+ let match;
94
+ while ((match = multilineRegex.exec(content)) !== null) {
95
+ entries.push({ key: match[1], value: match[2] });
96
+ remaining = remaining.replace(match[0], '');
97
+ }
98
+ for (const line of remaining.split('\n')) {
99
+ if (!line.trim() || line.trim().startsWith('#')) continue;
100
+ const lineMatch = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
101
+ if (lineMatch) {
102
+ entries.push({ key: lineMatch[1], value: lineMatch[2].replace(/^["']|["']$/g, '') });
103
+ }
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ // 値の比較
109
+ function compareValues(a, b) {
110
+ return a.trim().replace(/\r\n/g, '\n') === b.trim().replace(/\r\n/g, '\n');
111
+ }
112
+
113
+ // CLI モード
114
+ async function runCli(args) {
115
+ const command = args[0];
116
+ const config = getConfig();
117
+
118
+ if (!config.centralProject && command !== "init") {
119
+ console.error("エラー: 先に init を実行してください");
120
+ process.exit(1);
121
+ }
122
+
123
+ try {
124
+ switch (command) {
125
+ case "init": {
126
+ const projectId = args[1];
127
+ if (!projectId) {
128
+ console.error("使い方: gcloud-secrets init <project-id>");
129
+ process.exit(1);
130
+ }
131
+ const configFile = `${homedir()}/.secrets-manager.conf`;
132
+ writeFileSync(configFile, `SECRETS_CENTRAL_PROJECT=${projectId}\n`);
133
+ console.log(`設定完了: ${projectId}`);
134
+ break;
135
+ }
136
+
137
+ case "list": {
138
+ const folder = args[1];
139
+ const parent = `projects/${config.centralProject}`;
140
+ const [secrets] = await client.listSecrets({ parent });
141
+
142
+ if (!folder) {
143
+ const folders = new Set();
144
+ for (const secret of secrets) {
145
+ const [labels] = await client.getSecret({ name: secret.name });
146
+ if (labels.labels?.folder) {
147
+ folders.add(labels.labels.folder);
148
+ }
149
+ }
150
+ console.log("フォルダ一覧:");
151
+ for (const f of folders) {
152
+ console.log(` ${f}`);
153
+ }
154
+ } else {
155
+ console.log(`${folder} のシークレット:`);
156
+ for (const secret of secrets) {
157
+ const [secretData] = await client.getSecret({ name: secret.name });
158
+ if (secretData.labels?.folder === folder) {
159
+ const key = getKeyFromSecret(secret.name.split("/").pop());
160
+ console.log(` ${key}`);
161
+ }
162
+ }
163
+ }
164
+ break;
165
+ }
166
+
167
+ case "pull": {
168
+ const folder = normalizeFolder(args[1] || basename(process.cwd()));
169
+ const parent = `projects/${config.centralProject}`;
170
+ const [secrets] = await client.listSecrets({ parent });
171
+
172
+ const envLines = [];
173
+ for (const secret of secrets) {
174
+ const [secretData] = await client.getSecret({ name: secret.name });
175
+ if (secretData.labels?.folder === folder) {
176
+ const key = getKeyFromSecret(secret.name.split("/").pop());
177
+ const [version] = await client.accessSecretVersion({
178
+ name: `${secret.name}/versions/latest`,
179
+ });
180
+ const value = version.payload.data.toString("utf-8");
181
+ if (value.includes("\n")) {
182
+ envLines.push(`${key}=\`${value}\``);
183
+ } else {
184
+ envLines.push(`${key}=${value}`);
185
+ }
186
+ }
187
+ }
188
+ console.log(envLines.join("\n"));
189
+ break;
190
+ }
191
+
192
+ case "push": {
193
+ const folder = normalizeFolder(args[1] || basename(process.cwd()));
194
+ const envFile = args[2] || ".env";
195
+
196
+ if (!existsSync(envFile)) {
197
+ console.error(`ファイルが見つかりません: ${envFile}`);
198
+ process.exit(1);
199
+ }
200
+
201
+ const content = readFileSync(envFile, "utf-8");
202
+ const lines = content.split("\n");
203
+ const parent = `projects/${config.centralProject}`;
204
+ let count = 0;
205
+
206
+ for (const line of lines) {
207
+ if (!line.trim() || line.startsWith("#")) continue;
208
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/i);
209
+ if (match) {
210
+ const [, key, value] = match;
211
+ const secretId = makeSecretName(folder, key);
212
+ const secretName = `${parent}/secrets/${secretId}`;
213
+
214
+ try {
215
+ await client.getSecret({ name: secretName });
216
+ await client.addSecretVersion({
217
+ parent: secretName,
218
+ payload: { data: Buffer.from(value) },
219
+ });
220
+ } catch {
221
+ await client.createSecret({
222
+ parent,
223
+ secretId,
224
+ secret: { replication: { automatic: {} }, labels: { folder } },
225
+ });
226
+ await client.addSecretVersion({
227
+ parent: secretName,
228
+ payload: { data: Buffer.from(value) },
229
+ });
230
+ }
231
+ count++;
232
+ }
233
+ }
234
+ console.log(`${count} 件のシークレットをアップロードしました (${folder})`);
235
+ break;
236
+ }
237
+
238
+ case "scan": {
239
+ const basePath = args[1] || homedir();
240
+ const repos = findGitRepositories(basePath, 5);
241
+ const parent = `projects/${config.centralProject}`;
242
+ const [allSecrets] = await client.listSecrets({ parent });
243
+
244
+ // フォルダごとにグループ化
245
+ const secretsByFolder = new Map();
246
+ for (const secret of allSecrets) {
247
+ const [secretData] = await client.getSecret({ name: secret.name });
248
+ const f = secretData.labels?.folder;
249
+ if (f) {
250
+ if (!secretsByFolder.has(f)) secretsByFolder.set(f, []);
251
+ secretsByFolder.get(f).push(secret);
252
+ }
253
+ }
254
+
255
+ const results = [];
256
+ let syncedCount = 0, diffCount = 0, newCount = 0;
257
+
258
+ for (const repoPath of repos) {
259
+ const envFiles = findEnvFiles(repoPath);
260
+ if (envFiles.length === 0) continue;
261
+
262
+ const repoName = basename(resolve(repoPath));
263
+ const normalizedFolder = normalizeFolder(repoName);
264
+
265
+ for (const envFile of envFiles) {
266
+ let content;
267
+ try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
268
+ if (!content.trim()) continue;
269
+
270
+ const localEntries = parseEnvFile(content);
271
+ if (localEntries.length === 0) continue;
272
+
273
+ const folderSecrets = secretsByFolder.get(normalizedFolder) || [];
274
+
275
+ if (folderSecrets.length === 0) {
276
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
277
+ newCount++;
278
+ continue;
279
+ }
280
+
281
+ // リモート値取得・比較
282
+ let hasDiff = false;
283
+ const remoteKeys = new Set();
284
+ const remoteValues = new Map();
285
+
286
+ for (const secret of folderSecrets) {
287
+ const key = getKeyFromSecret(secret.name.split('/').pop());
288
+ remoteKeys.add(key);
289
+ try {
290
+ const [version] = await client.accessSecretVersion({ name: `${secret.name}/versions/latest` });
291
+ remoteValues.set(key, version.payload.data.toString('utf8'));
292
+ } catch { }
293
+ }
294
+
295
+ for (const entry of localEntries) {
296
+ if (!remoteKeys.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key) || '')) {
297
+ hasDiff = true;
298
+ break;
299
+ }
300
+ }
301
+ if (!hasDiff) {
302
+ for (const key of remoteKeys) {
303
+ if (!localEntries.find(e => e.key === key)) { hasDiff = true; break; }
304
+ }
305
+ }
306
+
307
+ if (hasDiff) {
308
+ results.push({ status: "DIFF", repo: repoName, file: envFile.filename, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
309
+ diffCount++;
310
+ } else {
311
+ results.push({ status: "OK", repo: repoName, file: envFile.filename, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
312
+ syncedCount++;
313
+ }
314
+ }
315
+ }
316
+
317
+ console.log("=== Secret Manager 同期状況 ===\n");
318
+ if (results.length === 0) {
319
+ console.log(".env / .dev.vars ファイルが見つかりませんでした");
320
+ } else {
321
+ for (const r of results) {
322
+ const label = r.status === "OK" ? "[OK] " : r.status === "DIFF" ? "[DIFF]" : "[NEW] ";
323
+ const suffix = r.status === "DIFF" ? " - 差分あり" : r.status === "NEW" ? " - 未登録" : "";
324
+ const warn = !r.gitIgnored ? " ⚠" : "";
325
+ console.log(`${label} ${r.repo}/ ${r.file} (${r.keyCount} keys)${suffix}${warn}`);
326
+ }
327
+ console.log(`\n---\n合計: ${results.length} ファイル`);
328
+ console.log(` 登録済み: ${syncedCount}`);
329
+ console.log(` 差分あり: ${diffCount}`);
330
+ console.log(` 未登録: ${newCount}`);
331
+ const notIgnored = results.filter(r => !r.gitIgnored);
332
+ if (notIgnored.length > 0) console.log(`\n⚠ .gitignore に含まれていないファイルがあります (${notIgnored.length}件)`);
333
+ }
334
+ break;
335
+ }
336
+
337
+ default:
338
+ console.log(`gcloud-secrets - GCP Secret Manager CLI
339
+
340
+ 使い方:
341
+ gcloud-secrets init <project-id> 中央プロジェクトを設定
342
+ gcloud-secrets list [folder] 一覧表示
343
+ gcloud-secrets pull [folder] シークレットを取得
344
+ gcloud-secrets push [folder] [file] シークレットをアップロード
345
+ gcloud-secrets scan [basePath] Git リポジトリの .env 同期状況をスキャン
346
+ `);
347
+ }
348
+ } catch (error) {
349
+ console.error(`エラー: ${error.message}`);
350
+ process.exit(1);
351
+ }
352
+ }
353
+
354
+ // メイン
355
+ runCli(process.argv.slice(2));
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, copyFileSync, readdirSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { homedir } from "os";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ // スキルのインストール先
11
+ const skillsDir = join(homedir(), ".claude", "skills");
12
+
13
+ // スキルディレクトリを作成
14
+ if (!existsSync(skillsDir)) {
15
+ mkdirSync(skillsDir, { recursive: true });
16
+ }
17
+
18
+ // スキルファイルをコピー
19
+ const sourceDir = join(__dirname, "skills");
20
+ if (existsSync(sourceDir)) {
21
+ const files = readdirSync(sourceDir);
22
+ for (const file of files) {
23
+ if (file.endsWith(".md")) {
24
+ const src = join(sourceDir, file);
25
+ const dest = join(skillsDir, file);
26
+ copyFileSync(src, dest);
27
+ console.log(`Installed skill: ${file} -> ${dest}`);
28
+ }
29
+ }
30
+ }
31
+
32
+ console.log("\ngcloud-secrets skills installed successfully!");
33
+ console.log("Use '/secrets' in Claude Code to see available commands.");
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@yhonda/gcloud-secrets",
3
+ "version": "2.0.1",
4
+ "description": "GCP Secret Manager を GitHub clone 風に管理する CLI ツール",
5
+ "type": "module",
6
+ "main": "cli.js",
7
+ "bin": {
8
+ "gcloud-secrets": "cli.js"
9
+ },
10
+ "files": [
11
+ "cli.js",
12
+ "skills/",
13
+ "install-skills.js",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node cli.js",
18
+ "postinstall": "node install-skills.js"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/yhonda-ohishi/gcloudSec.git"
23
+ },
24
+ "keywords": [
25
+ "gcp",
26
+ "secret-manager",
27
+ "env",
28
+ "claude-code",
29
+ "cli"
30
+ ],
31
+ "author": "yhonda",
32
+ "license": "ISC",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/yhonda-ohishi/gcloudSec/issues"
41
+ },
42
+ "homepage": "https://github.com/yhonda-ohishi/gcloudSec#readme",
43
+ "dependencies": {
44
+ "@google-cloud/secret-manager": "^6.1.1"
45
+ }
46
+ }
@@ -0,0 +1,83 @@
1
+ # Skill: secrets
2
+
3
+ GCP Secret Manager を使って .env ファイルを管理するスキル
4
+
5
+ ## コマンド一覧
6
+
7
+ ### 初期化
8
+ ```bash
9
+ gcloud-secrets init <project-id>
10
+ ```
11
+ GCP プロジェクト ID を設定します。
12
+
13
+ ### 一覧表示
14
+ ```bash
15
+ # フォルダ一覧
16
+ gcloud-secrets list
17
+
18
+ # 特定フォルダのシークレット一覧
19
+ gcloud-secrets list <folder>
20
+ ```
21
+
22
+ ### シークレット取得 (pull)
23
+ ```bash
24
+ # カレントディレクトリ名をフォルダ名として取得
25
+ gcloud-secrets pull
26
+
27
+ # 指定フォルダから取得
28
+ gcloud-secrets pull <folder>
29
+ ```
30
+ Secret Manager から .env 形式でシークレットを取得します。
31
+
32
+ ### シークレット登録 (push)
33
+ ```bash
34
+ # .env ファイルをアップロード
35
+ gcloud-secrets push
36
+
37
+ # 指定フォルダにアップロード
38
+ gcloud-secrets push <folder>
39
+
40
+ # 指定ファイルをアップロード
41
+ gcloud-secrets push <folder> <file>
42
+ ```
43
+
44
+ ### 同期状況スキャン (scan)
45
+ ```bash
46
+ # ホームディレクトリ以下をスキャン
47
+ gcloud-secrets scan
48
+
49
+ # 指定ディレクトリ以下をスキャン
50
+ gcloud-secrets scan <path>
51
+ ```
52
+ Git リポジトリ内の .env / .dev.vars ファイルと Secret Manager の同期状況を確認します。
53
+
54
+ 出力例:
55
+ ```
56
+ === Secret Manager 同期状況 ===
57
+
58
+ [OK] project-a/ .env (3 keys)
59
+ [DIFF] project-b/ .env (2 keys) - 差分あり
60
+ [NEW] project-c/ .dev.vars (5 keys) - 未登録
61
+
62
+ ---
63
+ 合計: 3 ファイル
64
+ 登録済み: 1
65
+ 差分あり: 1
66
+ 未登録: 1
67
+ ```
68
+
69
+ ## 使用例
70
+
71
+ ```bash
72
+ # 1. 初期化
73
+ gcloud-secrets init my-gcp-project
74
+
75
+ # 2. 現在のプロジェクトの .env を登録
76
+ gcloud-secrets push
77
+
78
+ # 3. 別環境で取得
79
+ gcloud-secrets pull > .env
80
+
81
+ # 4. 全リポジトリの同期状況を確認
82
+ gcloud-secrets scan ~/
83
+ ```