@yhonda/gcloud-secrets 2.0.4 → 2.0.6

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.
Files changed (3) hide show
  1. package/cli.js +159 -81
  2. package/package.json +1 -1
  3. package/skills/secrets.md +39 -23
package/cli.js CHANGED
@@ -9,28 +9,64 @@ import { execSync } from "child_process";
9
9
  // SDK クライアント初期化
10
10
  const client = new SecretManagerServiceClient();
11
11
 
12
+ // 引数パース (--env / -e オプション抽出)
13
+ function parseArgs(args) {
14
+ const result = { positional: [], env: null };
15
+ for (let i = 0; i < args.length; i++) {
16
+ if (args[i] === '--env' || args[i] === '-e') {
17
+ result.env = args[i + 1];
18
+ i++;
19
+ } else if (args[i].startsWith('--env=')) {
20
+ result.env = args[i].split('=')[1];
21
+ } else {
22
+ result.positional.push(args[i]);
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
12
28
  // 設定読み込み
13
29
  function getConfig() {
14
30
  const configFile = `${homedir()}/.secrets-manager.conf`;
31
+ const config = {
32
+ centralProject: process.env.SECRETS_CENTRAL_PROJECT || "",
33
+ defaultEnvironment: process.env.DEFAULT_ENVIRONMENT || "dev"
34
+ };
15
35
  if (existsSync(configFile)) {
16
36
  const content = readFileSync(configFile, "utf-8");
17
- const match = content.match(/SECRETS_CENTRAL_PROJECT=(.+)/);
18
- if (match) {
19
- return { centralProject: match[1].trim() };
37
+ const projectMatch = content.match(/SECRETS_CENTRAL_PROJECT=(.+)/);
38
+ if (projectMatch) {
39
+ config.centralProject = projectMatch[1].trim();
40
+ }
41
+ const envMatch = content.match(/DEFAULT_ENVIRONMENT=(.+)/);
42
+ if (envMatch) {
43
+ config.defaultEnvironment = envMatch[1].trim();
20
44
  }
21
45
  }
22
- return { centralProject: process.env.SECRETS_CENTRAL_PROJECT || "" };
46
+ return config;
23
47
  }
24
48
 
25
- // シークレット名生成
26
- function makeSecretName(folder, key) {
49
+ // シークレット名生成 (環境対応)
50
+ function makeSecretName(folder, key, env = null) {
51
+ if (env) {
52
+ return `${folder}_${env}_${key}`;
53
+ }
27
54
  return `${folder}_${key}`;
28
55
  }
29
56
 
30
- // シークレット名からキーを抽出
31
- function getKeyFromSecret(secretName) {
32
- const parts = secretName.split("_");
33
- return parts.slice(1).join("_");
57
+ // シークレット名からキーと環境を抽出
58
+ function getKeyFromSecret(secretName, folderName) {
59
+ const prefix = folderName + "_";
60
+ if (!secretName.startsWith(prefix)) {
61
+ return { key: secretName, env: null };
62
+ }
63
+ const rest = secretName.slice(prefix.length);
64
+ const parts = rest.split("_");
65
+ // 2つ以上のパートがあり、最初がアルファベット小文字のみなら環境名と判断
66
+ if (parts.length >= 2 && /^[a-z]+$/.test(parts[0])) {
67
+ return { key: parts.slice(1).join("_"), env: parts[0] };
68
+ }
69
+ return { key: rest, env: null };
34
70
  }
35
71
 
36
72
  // フォルダ名を正規化 (camelCase → kebab-case)
@@ -112,8 +148,10 @@ function compareValues(a, b) {
112
148
 
113
149
  // CLI モード
114
150
  async function runCli(args) {
115
- const command = args[0];
151
+ const parsed = parseArgs(args);
152
+ const command = parsed.positional[0];
116
153
  const config = getConfig();
154
+ const targetEnv = parsed.env || config.defaultEnvironment;
117
155
 
118
156
  if (!config.centralProject && command !== "init") {
119
157
  console.error("エラー: 先に init を実行してください");
@@ -123,40 +161,49 @@ async function runCli(args) {
123
161
  try {
124
162
  switch (command) {
125
163
  case "init": {
126
- const projectId = args[1];
164
+ const projectId = parsed.positional[1];
165
+ const defaultEnv = parsed.env || "dev";
127
166
  if (!projectId) {
128
- console.error("使い方: gcloud-secrets init <project-id>");
167
+ console.error("使い方: gcloud-secrets init <project-id> [--env <default-env>]");
129
168
  process.exit(1);
130
169
  }
131
170
  const configFile = `${homedir()}/.secrets-manager.conf`;
132
- writeFileSync(configFile, `SECRETS_CENTRAL_PROJECT=${projectId}\n`);
133
- console.log(`設定完了: ${projectId}`);
171
+ const configContent = `SECRETS_CENTRAL_PROJECT=${projectId}\nDEFAULT_ENVIRONMENT=${defaultEnv}\n`;
172
+ writeFileSync(configFile, configContent);
173
+ console.log(`設定完了: ${projectId} (デフォルト環境: ${defaultEnv})`);
134
174
  break;
135
175
  }
136
176
 
137
177
  case "list": {
138
- const folder = args[1];
178
+ const folder = parsed.positional[1];
139
179
  const parent = `projects/${config.centralProject}`;
140
180
  const [secrets] = await client.listSecrets({ parent });
141
181
 
142
182
  if (!folder) {
143
- const folders = new Set();
183
+ // フォルダ一覧 (環境ごとにグループ化)
184
+ const folderEnvs = new Map();
144
185
  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);
186
+ const [secretData] = await client.getSecret({ name: secret.name });
187
+ if (secretData.labels?.folder) {
188
+ const f = secretData.labels.folder;
189
+ const e = secretData.labels?.environment || "(default)";
190
+ if (!folderEnvs.has(f)) folderEnvs.set(f, new Set());
191
+ folderEnvs.get(f).add(e);
148
192
  }
149
193
  }
150
194
  console.log("フォルダ一覧:");
151
- for (const f of folders) {
152
- console.log(` ${f}`);
195
+ for (const [f, envs] of folderEnvs) {
196
+ const envList = Array.from(envs).sort().join(', ');
197
+ console.log(` ${f} [${envList}]`);
153
198
  }
154
199
  } else {
155
- console.log(`${folder} のシークレット:`);
200
+ // 特定フォルダのシークレット一覧 (環境でフィルタ)
201
+ console.log(`${folder} (${targetEnv}) のシークレット:`);
156
202
  for (const secret of secrets) {
157
203
  const [secretData] = await client.getSecret({ name: secret.name });
158
- if (secretData.labels?.folder === folder) {
159
- const key = getKeyFromSecret(secret.name.split("/").pop());
204
+ const secretEnv = secretData.labels?.environment || null;
205
+ if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
206
+ const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
160
207
  console.log(` ${key}`);
161
208
  }
162
209
  }
@@ -165,15 +212,16 @@ async function runCli(args) {
165
212
  }
166
213
 
167
214
  case "pull": {
168
- const folder = normalizeFolder(args[1] || basename(process.cwd()));
215
+ const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
169
216
  const parent = `projects/${config.centralProject}`;
170
217
  const [secrets] = await client.listSecrets({ parent });
171
218
 
172
219
  const envLines = [];
173
220
  for (const secret of secrets) {
174
221
  const [secretData] = await client.getSecret({ name: secret.name });
175
- if (secretData.labels?.folder === folder) {
176
- const key = getKeyFromSecret(secret.name.split("/").pop());
222
+ const secretEnv = secretData.labels?.environment || null;
223
+ if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
224
+ const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
177
225
  const [version] = await client.accessSecretVersion({
178
226
  name: `${secret.name}/versions/latest`,
179
227
  });
@@ -185,13 +233,17 @@ async function runCli(args) {
185
233
  }
186
234
  }
187
235
  }
188
- console.log(envLines.join("\n"));
236
+ if (envLines.length === 0) {
237
+ console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
238
+ } else {
239
+ console.log(envLines.join("\n"));
240
+ }
189
241
  break;
190
242
  }
191
243
 
192
244
  case "push": {
193
- const folder = normalizeFolder(args[1] || basename(process.cwd()));
194
- const envFile = args[2] || ".env";
245
+ const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
246
+ const envFile = parsed.positional[2] || ".env";
195
247
 
196
248
  if (!existsSync(envFile)) {
197
249
  console.error(`ファイルが見つかりません: ${envFile}`);
@@ -201,6 +253,7 @@ async function runCli(args) {
201
253
  const content = readFileSync(envFile, "utf-8");
202
254
  const lines = content.split("\n");
203
255
  const parent = `projects/${config.centralProject}`;
256
+ const labels = { folder, environment: targetEnv };
204
257
  let count = 0;
205
258
 
206
259
  for (const line of lines) {
@@ -208,11 +261,16 @@ async function runCli(args) {
208
261
  const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/i);
209
262
  if (match) {
210
263
  const [, key, value] = match;
211
- const secretId = makeSecretName(folder, key);
264
+ const secretId = makeSecretName(folder, key, targetEnv);
212
265
  const secretName = `${parent}/secrets/${secretId}`;
213
266
 
214
267
  try {
215
268
  await client.getSecret({ name: secretName });
269
+ // 既存シークレットのラベルも更新
270
+ await client.updateSecret({
271
+ secret: { name: secretName, labels },
272
+ updateMask: { paths: ['labels'] }
273
+ });
216
274
  await client.addSecretVersion({
217
275
  parent: secretName,
218
276
  payload: { data: Buffer.from(value) },
@@ -221,7 +279,7 @@ async function runCli(args) {
221
279
  await client.createSecret({
222
280
  parent,
223
281
  secretId,
224
- secret: { replication: { automatic: {} }, labels: { folder } },
282
+ secret: { replication: { automatic: {} }, labels },
225
283
  });
226
284
  await client.addSecretVersion({
227
285
  parent: secretName,
@@ -231,24 +289,27 @@ async function runCli(args) {
231
289
  count++;
232
290
  }
233
291
  }
234
- console.log(`${count} 件のシークレットをアップロードしました (${folder})`);
292
+ console.log(`${count} 件のシークレットをアップロードしました (${folder}/${targetEnv})`);
235
293
  break;
236
294
  }
237
295
 
238
296
  case "scan": {
239
- const basePath = args[1] || homedir();
297
+ const basePath = parsed.positional[1] || homedir();
298
+ const filterEnv = parsed.env; // null の場合は全環境を表示
240
299
  const repos = findGitRepositories(basePath, 5);
241
300
  const parent = `projects/${config.centralProject}`;
242
301
  const [allSecrets] = await client.listSecrets({ parent });
243
302
 
244
- // フォルダごとにグループ化
245
- const secretsByFolder = new Map();
303
+ // フォルダ+環境ごとにグループ化
304
+ const secretsByFolderEnv = new Map();
246
305
  for (const secret of allSecrets) {
247
306
  const [secretData] = await client.getSecret({ name: secret.name });
248
307
  const f = secretData.labels?.folder;
308
+ const e = secretData.labels?.environment || null;
249
309
  if (f) {
250
- if (!secretsByFolder.has(f)) secretsByFolder.set(f, []);
251
- secretsByFolder.get(f).push(secret);
310
+ const key = `${f}|${e || ''}`;
311
+ if (!secretsByFolderEnv.has(key)) secretsByFolderEnv.set(key, []);
312
+ secretsByFolderEnv.get(key).push({ secret, env: e });
252
313
  }
253
314
  }
254
315
 
@@ -270,51 +331,64 @@ async function runCli(args) {
270
331
  const localEntries = parseEnvFile(content);
271
332
  if (localEntries.length === 0) continue;
272
333
 
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
- }
334
+ // 環境フィルタがある場合はその環境のみ、なければ全環境をチェック
335
+ const envsToCheck = filterEnv ? [filterEnv] : [null, ...Array.from(new Set(
336
+ Array.from(secretsByFolderEnv.keys())
337
+ .filter(k => k.startsWith(normalizedFolder + '|'))
338
+ .map(k => k.split('|')[1])
339
+ .filter(Boolean)
340
+ ))];
341
+
342
+ for (const checkEnv of envsToCheck) {
343
+ const mapKey = `${normalizedFolder}|${checkEnv || ''}`;
344
+ const folderSecrets = secretsByFolderEnv.get(mapKey) || [];
345
+ const envLabel = checkEnv || "(default)";
346
+
347
+ if (folderSecrets.length === 0) {
348
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
349
+ newCount++;
350
+ continue;
351
+ }
280
352
 
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
- }
353
+ // リモート値取得・比較
354
+ let hasDiff = false;
355
+ const remoteKeys = new Set();
356
+ const remoteValues = new Map();
357
+
358
+ for (const { secret } of folderSecrets) {
359
+ const { key } = getKeyFromSecret(secret.name.split('/').pop(), normalizedFolder);
360
+ remoteKeys.add(key);
361
+ try {
362
+ const [version] = await client.accessSecretVersion({ name: `${secret.name}/versions/latest` });
363
+ remoteValues.set(key, version.payload.data.toString('utf8'));
364
+ } catch { }
365
+ }
294
366
 
295
- for (const entry of localEntries) {
296
- if (!remoteKeys.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key) || '')) {
297
- hasDiff = true;
298
- break;
367
+ for (const entry of localEntries) {
368
+ if (!remoteKeys.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key) || '')) {
369
+ hasDiff = true;
370
+ break;
371
+ }
299
372
  }
300
- }
301
- if (!hasDiff) {
302
- for (const key of remoteKeys) {
303
- if (!localEntries.find(e => e.key === key)) { hasDiff = true; break; }
373
+ if (!hasDiff) {
374
+ for (const key of remoteKeys) {
375
+ if (!localEntries.find(e => e.key === key)) { hasDiff = true; break; }
376
+ }
304
377
  }
305
- }
306
378
 
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++;
379
+ if (hasDiff) {
380
+ results.push({ status: "DIFF", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
381
+ diffCount++;
382
+ } else {
383
+ results.push({ status: "OK", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
384
+ syncedCount++;
385
+ }
313
386
  }
314
387
  }
315
388
  }
316
389
 
317
- console.log("=== Secret Manager 同期状況 ===\n");
390
+ const envSuffix = filterEnv ? ` (${filterEnv})` : "";
391
+ console.log(`=== Secret Manager 同期状況${envSuffix} ===\n`);
318
392
  if (results.length === 0) {
319
393
  console.log(".env / .dev.vars ファイルが見つかりませんでした");
320
394
  } else {
@@ -322,7 +396,7 @@ async function runCli(args) {
322
396
  const label = r.status === "OK" ? "[OK] " : r.status === "DIFF" ? "[DIFF]" : "[NEW] ";
323
397
  const suffix = r.status === "DIFF" ? " - 差分あり" : r.status === "NEW" ? " - 未登録" : "";
324
398
  const warn = !r.gitIgnored ? " ⚠" : "";
325
- console.log(`${label} ${r.repo}/ ${r.file} (${r.keyCount} keys)${suffix}${warn}`);
399
+ console.log(`${label} ${r.repo}/ ${r.file} [${r.env}] (${r.keyCount} keys)${suffix}${warn}`);
326
400
  }
327
401
  console.log(`\n---\n合計: ${results.length} ファイル`);
328
402
  console.log(` 登録済み: ${syncedCount}`);
@@ -338,11 +412,15 @@ async function runCli(args) {
338
412
  console.log(`gcloud-secrets - GCP Secret Manager CLI
339
413
 
340
414
  使い方:
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 同期状況をスキャン
415
+ gcloud-secrets init <project-id> [--env <default>] 中央プロジェクトを設定
416
+ gcloud-secrets list [folder] [--env <env>] 一覧表示
417
+ gcloud-secrets pull [folder] [--env <env>] シークレットを取得
418
+ gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
419
+ gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
420
+
421
+ オプション:
422
+ --env, -e <env> 環境を指定 (dev, staging, prod など)
423
+ 省略時は設定ファイルの DEFAULT_ENVIRONMENT を使用
346
424
  `);
347
425
  }
348
426
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhonda/gcloud-secrets",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "GCP Secret Manager を GitHub clone 風に管理する CLI ツール",
5
5
  "type": "module",
6
6
  "main": "cli.js",
package/skills/secrets.md CHANGED
@@ -6,48 +6,51 @@ GCP Secret Manager を使って .env ファイルを管理するスキル
6
6
 
7
7
  ### 初期化
8
8
  ```bash
9
- gcloud-secrets init <project-id>
9
+ gcloud-secrets init <project-id> [--env <default>]
10
10
  ```
11
- GCP プロジェクト ID を設定します。
11
+ GCP プロジェクト ID を設定します。`--env` でデフォルト環境を指定できます(省略時は `dev`)。
12
12
 
13
13
  ### 一覧表示
14
14
  ```bash
15
- # フォルダ一覧
15
+ # フォルダ一覧 (環境ごとにグループ化)
16
16
  gcloud-secrets list
17
17
 
18
- # 特定フォルダのシークレット一覧
19
- gcloud-secrets list <folder>
18
+ # 特定フォルダ・環境のシークレット一覧
19
+ gcloud-secrets list <folder> --env dev
20
20
  ```
21
21
 
22
22
  ### シークレット取得 (pull)
23
23
  ```bash
24
24
  # カレントディレクトリ名をフォルダ名として取得
25
- gcloud-secrets pull
25
+ gcloud-secrets pull --env dev
26
26
 
27
27
  # 指定フォルダから取得
28
- gcloud-secrets pull <folder>
28
+ gcloud-secrets pull <folder> --env prod
29
29
  ```
30
30
  Secret Manager から .env 形式でシークレットを取得します。
31
31
 
32
32
  ### シークレット登録 (push)
33
33
  ```bash
34
- # .env ファイルをアップロード
35
- gcloud-secrets push
34
+ # .env ファイルをアップロード (dev 環境)
35
+ gcloud-secrets push --env dev
36
36
 
37
- # 指定フォルダにアップロード
38
- gcloud-secrets push <folder>
37
+ # 指定フォルダにアップロード (prod 環境)
38
+ gcloud-secrets push <folder> --env prod
39
39
 
40
40
  # 指定ファイルをアップロード
41
- gcloud-secrets push <folder> <file>
41
+ gcloud-secrets push <folder> <file> --env staging
42
42
  ```
43
43
 
44
44
  ### 同期状況スキャン (scan)
45
45
  ```bash
46
- # ホームディレクトリ以下をスキャン
46
+ # ホームディレクトリ以下をスキャン (全環境)
47
47
  gcloud-secrets scan
48
48
 
49
+ # 特定環境のみスキャン
50
+ gcloud-secrets scan --env dev
51
+
49
52
  # 指定ディレクトリ以下をスキャン
50
- gcloud-secrets scan <path>
53
+ gcloud-secrets scan <path> --env prod
51
54
  ```
52
55
  Git リポジトリ内の .env / .dev.vars ファイルと Secret Manager の同期状況を確認します。
53
56
 
@@ -55,9 +58,9 @@ Git リポジトリ内の .env / .dev.vars ファイルと Secret Manager の同
55
58
  ```
56
59
  === Secret Manager 同期状況 ===
57
60
 
58
- [OK] project-a/ .env (3 keys)
59
- [DIFF] project-b/ .env (2 keys) - 差分あり
60
- [NEW] project-c/ .dev.vars (5 keys) - 未登録
61
+ [OK] project-a/ .env [dev] (3 keys)
62
+ [DIFF] project-b/ .env [prod] (2 keys) - 差分あり
63
+ [NEW] project-c/ .dev.vars [dev] (5 keys) - 未登録
61
64
 
62
65
  ---
63
66
  合計: 3 ファイル
@@ -66,18 +69,31 @@ Git リポジトリ内の .env / .dev.vars ファイルと Secret Manager の同
66
69
  未登録: 1
67
70
  ```
68
71
 
72
+ ## 環境 (Environment) オプション
73
+
74
+ `--env` または `-e` で環境を指定できます:
75
+ - `dev` - 開発環境
76
+ - `staging` - ステージング環境
77
+ - `prod` - 本番環境
78
+ - その他任意の文字列
79
+
80
+ デフォルト環境は `~/.secrets-manager.conf` の `DEFAULT_ENVIRONMENT` で設定されます。
81
+
69
82
  ## 使用例
70
83
 
71
84
  ```bash
72
- # 1. 初期化
73
- gcloud-secrets init my-gcp-project
85
+ # 1. 初期化 (デフォルト環境を dev に設定)
86
+ gcloud-secrets init my-gcp-project --env dev
74
87
 
75
- # 2. 現在のプロジェクトの .env を登録
76
- gcloud-secrets push
88
+ # 2. dev 環境に .env を登録
89
+ gcloud-secrets push --env dev
77
90
 
78
- # 3. 別環境で取得
79
- gcloud-secrets pull > .env
91
+ # 3. prod 環境から取得
92
+ gcloud-secrets pull --env prod > .env.prod
80
93
 
81
94
  # 4. 全リポジトリの同期状況を確認
82
95
  gcloud-secrets scan ~/
96
+
97
+ # 5. dev 環境のみスキャン
98
+ gcloud-secrets scan ~/ --env dev
83
99
  ```