@yhonda/gcloud-secrets 2.0.11 → 3.0.0

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 (4) hide show
  1. package/README.md +84 -36
  2. package/cli.js +761 -218
  3. package/package.json +6 -5
  4. package/skills/secrets.md +40 -10
package/cli.js CHANGED
@@ -1,23 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
4
- import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync } from "fs";
3
+ import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, mkdirSync, chmodSync, rmSync } from "fs";
4
+ import { createHash } from "crypto";
5
5
  import { basename, join, dirname, resolve } from "path";
6
6
  import { homedir } from "os";
7
- import { execSync } from "child_process";
7
+ import { execSync, execFileSync } from "child_process";
8
+ import { createServer } from "http";
9
+ import { google } from "googleapis";
10
+ import { Readable } from "stream";
8
11
 
9
- // SDK クライアント初期化
10
- const client = new SecretManagerServiceClient();
11
-
12
- // 引数パース (--env / -e オプション抽出)
12
+ // ============================================================
13
+ // 引数パース
14
+ // ============================================================
13
15
  function parseArgs(args) {
14
- const result = { positional: [], env: null };
16
+ const result = { positional: [], env: null, ageKey: null, agePub: null, clientId: null, clientSecret: null };
15
17
  for (let i = 0; i < args.length; i++) {
16
18
  if (args[i] === '--env' || args[i] === '-e') {
17
- result.env = args[i + 1];
18
- i++;
19
+ result.env = args[i + 1]; i++;
19
20
  } else if (args[i].startsWith('--env=')) {
20
21
  result.env = args[i].split('=')[1];
22
+ } else if (args[i] === '--age-key') {
23
+ result.ageKey = args[i + 1]; i++;
24
+ } else if (args[i] === '--age-pub') {
25
+ result.agePub = args[i + 1]; i++;
26
+ } else if (args[i] === '--client-id') {
27
+ result.clientId = args[i + 1]; i++;
28
+ } else if (args[i] === '--client-secret') {
29
+ result.clientSecret = args[i + 1]; i++;
21
30
  } else {
22
31
  result.positional.push(args[i]);
23
32
  }
@@ -25,75 +34,312 @@ function parseArgs(args) {
25
34
  return result;
26
35
  }
27
36
 
28
- // 設定読み込み
37
+ // ============================================================
38
+ // 設定
39
+ // ============================================================
29
40
  function getConfig() {
30
41
  const configFile = `${homedir()}/.secrets-manager.conf`;
31
42
  const config = {
32
- centralProject: process.env.SECRETS_CENTRAL_PROJECT || "",
33
- defaultEnvironment: process.env.DEFAULT_ENVIRONMENT || "dev"
43
+ driveFolderId: process.env.DRIVE_FOLDER_ID || "",
44
+ defaultEnvironment: process.env.DEFAULT_ENVIRONMENT || "dev",
45
+ agePublicKey: process.env.AGE_PUBLIC_KEY || "",
46
+ ageKeyPath: process.env.AGE_KEY_PATH || join(homedir(), ".age", "key.txt"),
47
+ googleClientId: process.env.GOOGLE_CLIENT_ID || "",
48
+ googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
34
49
  };
35
50
  if (existsSync(configFile)) {
36
51
  const content = readFileSync(configFile, "utf-8");
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();
52
+ for (const [envKey, configKey] of [
53
+ ['DRIVE_FOLDER_ID', 'driveFolderId'],
54
+ ['DEFAULT_ENVIRONMENT', 'defaultEnvironment'],
55
+ ['AGE_PUBLIC_KEY', 'agePublicKey'],
56
+ ['AGE_KEY_PATH', 'ageKeyPath'],
57
+ ['GOOGLE_CLIENT_ID', 'googleClientId'],
58
+ ['GOOGLE_CLIENT_SECRET', 'googleClientSecret'],
59
+ ]) {
60
+ const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm'));
61
+ if (match) config[configKey] = match[1].trim();
44
62
  }
45
63
  }
46
64
  return config;
47
65
  }
48
66
 
49
- // シークレット名生成 (環境対応)
50
- function makeSecretName(folder, key, env = null) {
51
- if (env) {
52
- return `${folder}_${env}_${key}`;
67
+ function writeConfig(values) {
68
+ const configFile = `${homedir()}/.secrets-manager.conf`;
69
+ const lines = [];
70
+ for (const [key, value] of Object.entries(values)) {
71
+ if (value) lines.push(`${key}=${value}`);
72
+ }
73
+ writeFileSync(configFile, lines.join('\n') + '\n');
74
+ }
75
+
76
+ // ============================================================
77
+ // age ヘルパー
78
+ // ============================================================
79
+ function checkAgeInstalled() {
80
+ try {
81
+ execFileSync('age', ['--version'], { stdio: 'ignore' });
82
+ } catch {
83
+ console.error('エラー: age がインストールされていません');
84
+ console.error('インストール: sudo apt install age (Linux) / brew install age (macOS)');
85
+ process.exit(1);
53
86
  }
54
- return `${folder}_${key}`;
55
87
  }
56
88
 
57
- // シークレット名からキーと環境を抽出
58
- function getKeyFromSecret(secretName, folderName) {
59
- const prefix = folderName + "_";
60
- if (!secretName.startsWith(prefix)) {
61
- return { key: secretName, env: null };
89
+ function ageEncrypt(plaintext, publicKey) {
90
+ return execFileSync('age', ['-r', publicKey], { input: Buffer.from(plaintext) });
91
+ }
92
+
93
+ function ageDecrypt(ciphertext, keyPath) {
94
+ return execFileSync('age', ['-d', '-i', keyPath], { input: ciphertext, encoding: 'utf-8' });
95
+ }
96
+
97
+ function getAgePublicKeyFromFile(keyPath) {
98
+ const content = readFileSync(keyPath, 'utf-8');
99
+ const match = content.match(/# public key: (age1[a-z0-9]+)/);
100
+ if (match) return match[1];
101
+ throw new Error('age 公開鍵が見つかりません: ' + keyPath);
102
+ }
103
+
104
+ // ============================================================
105
+ // OAuth2 認証
106
+ // ============================================================
107
+ const OAUTH_REDIRECT_PORT = 3456;
108
+ const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_REDIRECT_PORT}/callback`;
109
+ const OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive'];
110
+
111
+ function getTokenPath() {
112
+ return join(homedir(), '.secrets-manager-oauth.json');
113
+ }
114
+
115
+ async function getAuthClient(config) {
116
+ if (!config.googleClientId || !config.googleClientSecret) {
117
+ console.error('エラー: Google OAuth クライアント ID/Secret が設定されていません');
118
+ console.error('init コマンドで --client-id と --client-secret を指定してください');
119
+ process.exit(1);
62
120
  }
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] };
121
+
122
+ const oauth2Client = new google.auth.OAuth2(
123
+ config.googleClientId,
124
+ config.googleClientSecret,
125
+ OAUTH_REDIRECT_URI
126
+ );
127
+
128
+ const tokenPath = getTokenPath();
129
+ if (existsSync(tokenPath)) {
130
+ const tokens = JSON.parse(readFileSync(tokenPath, 'utf-8'));
131
+ oauth2Client.setCredentials(tokens);
132
+ oauth2Client.on('tokens', (newTokens) => {
133
+ try {
134
+ const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
135
+ writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
136
+ } catch { }
137
+ });
138
+ return oauth2Client;
68
139
  }
69
- return { key: rest, env: null };
140
+
141
+ // 初回認証フロー
142
+ const authUrl = oauth2Client.generateAuthUrl({
143
+ access_type: 'offline',
144
+ scope: OAUTH_SCOPES,
145
+ prompt: 'consent',
146
+ });
147
+
148
+ console.log('ブラウザで認証を行います...');
149
+ try {
150
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
151
+ execSync(`${openCmd} "${authUrl}"`, { stdio: 'ignore' });
152
+ } catch {
153
+ console.log(`以下のURLをブラウザで開いてください:\n${authUrl}`);
154
+ }
155
+
156
+ const code = await new Promise((resolve, reject) => {
157
+ const server = createServer((req, res) => {
158
+ const url = new URL(req.url, `http://localhost:${OAUTH_REDIRECT_PORT}`);
159
+ const authCode = url.searchParams.get('code');
160
+ const error = url.searchParams.get('error');
161
+ if (error) {
162
+ res.end('認証がキャンセルされました。');
163
+ server.close();
164
+ reject(new Error(`OAuth error: ${error}`));
165
+ return;
166
+ }
167
+ if (authCode) {
168
+ res.end('認証完了!このタブを閉じてください。');
169
+ server.close();
170
+ resolve(authCode);
171
+ }
172
+ });
173
+ server.listen(OAUTH_REDIRECT_PORT, () => {
174
+ console.log(`認証待機中... (localhost:${OAUTH_REDIRECT_PORT})`);
175
+ });
176
+ });
177
+
178
+ const { tokens } = await oauth2Client.getToken(code);
179
+ oauth2Client.setCredentials(tokens);
180
+ writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
181
+ console.log('認証完了');
182
+
183
+ oauth2Client.on('tokens', (newTokens) => {
184
+ try {
185
+ const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
186
+ writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
187
+ } catch { }
188
+ });
189
+
190
+ return oauth2Client;
191
+ }
192
+
193
+ async function getDriveClient(config) {
194
+ const auth = await getAuthClient(config);
195
+ return google.drive({ version: 'v3', auth });
196
+ }
197
+
198
+ // ============================================================
199
+ // Drive ヘルパー
200
+ // ============================================================
201
+ function escapeQuery(str) {
202
+ return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
203
+ }
204
+
205
+ async function listDriveFolders(drive, rootFolderId) {
206
+ const res = await drive.files.list({
207
+ q: `'${escapeQuery(rootFolderId)}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
208
+ fields: 'files(id, name)',
209
+ pageSize: 1000,
210
+ });
211
+ return res.data.files || [];
212
+ }
213
+
214
+ async function findFolder(drive, parentId, folderName) {
215
+ const res = await drive.files.list({
216
+ q: `'${escapeQuery(parentId)}' in parents and name = '${escapeQuery(folderName)}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
217
+ fields: 'files(id, name)',
218
+ pageSize: 1,
219
+ });
220
+ return (res.data.files || [])[0] || null;
221
+ }
222
+
223
+ async function getOrCreateFolder(drive, parentId, folderName) {
224
+ const existing = await findFolder(drive, parentId, folderName);
225
+ if (existing) return existing;
226
+
227
+ const res = await drive.files.create({
228
+ requestBody: {
229
+ name: folderName,
230
+ mimeType: 'application/vnd.google-apps.folder',
231
+ parents: [parentId],
232
+ },
233
+ fields: 'id, name',
234
+ });
235
+ return res.data;
236
+ }
237
+
238
+ async function findEnvAgeFile(drive, parentFolderId, env) {
239
+ const fileName = `${env}.env.age`;
240
+ const res = await drive.files.list({
241
+ q: `'${escapeQuery(parentFolderId)}' in parents and name = '${escapeQuery(fileName)}' and trashed = false`,
242
+ fields: 'files(id, name)',
243
+ pageSize: 1,
244
+ });
245
+ return (res.data.files || [])[0] || null;
246
+ }
247
+
248
+ async function listEnvAgeFiles(drive, parentFolderId) {
249
+ const res = await drive.files.list({
250
+ q: `'${escapeQuery(parentFolderId)}' in parents and name contains '.env.age' and trashed = false`,
251
+ fields: 'files(id, name)',
252
+ pageSize: 1000,
253
+ });
254
+ return res.data.files || [];
70
255
  }
71
256
 
72
- // フォルダ名を正規化 (camelCase kebab-case)
257
+ async function downloadFile(drive, fileId) {
258
+ const res = await drive.files.get({
259
+ fileId,
260
+ alt: 'media',
261
+ }, { responseType: 'arraybuffer' });
262
+ return Buffer.from(res.data);
263
+ }
264
+
265
+ async function uploadFile(drive, parentFolderId, fileName, content) {
266
+ const res = await drive.files.list({
267
+ q: `'${escapeQuery(parentFolderId)}' in parents and name = '${escapeQuery(fileName)}' and trashed = false`,
268
+ fields: 'files(id)',
269
+ pageSize: 1,
270
+ });
271
+
272
+ const existing = (res.data.files || [])[0];
273
+
274
+ if (existing) {
275
+ await drive.files.update({
276
+ fileId: existing.id,
277
+ media: {
278
+ mimeType: 'application/octet-stream',
279
+ body: Readable.from(content),
280
+ },
281
+ });
282
+ return existing.id;
283
+ } else {
284
+ const created = await drive.files.create({
285
+ requestBody: {
286
+ name: fileName,
287
+ parents: [parentFolderId],
288
+ },
289
+ media: {
290
+ mimeType: 'application/octet-stream',
291
+ body: Readable.from(content),
292
+ },
293
+ fields: 'id',
294
+ });
295
+ return created.data.id;
296
+ }
297
+ }
298
+
299
+ async function listAllEnvFiles(drive, rootFolderId) {
300
+ const folders = await listDriveFolders(drive, rootFolderId);
301
+ const result = [];
302
+
303
+ await Promise.all(folders.map(async (folder) => {
304
+ const files = await listEnvAgeFiles(drive, folder.id);
305
+ for (const file of files) {
306
+ const envMatch = file.name.match(/^(.+)\.env\.age$/);
307
+ if (envMatch) {
308
+ result.push({
309
+ fileId: file.id,
310
+ fileName: file.name,
311
+ folder: folder.name,
312
+ folderId: folder.id,
313
+ env: envMatch[1],
314
+ });
315
+ }
316
+ }
317
+ }));
318
+
319
+ return result;
320
+ }
321
+
322
+ // ============================================================
323
+ // ユーティリティ (変更なし)
324
+ // ============================================================
73
325
  function normalizeFolder(name) {
74
326
  return name
75
- .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase → kebab-case
327
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
76
328
  .toLowerCase()
77
329
  .replace(/[^a-z0-9_-]/g, '-');
78
330
  }
79
331
 
80
- // Git リポジトリを再帰的に検索
81
332
  function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
82
333
  const repos = [];
83
334
  if (currentDepth > maxDepth) return repos;
84
-
85
335
  try {
86
336
  const entries = readdirSync(basePath, { withFileTypes: true });
87
337
  for (const entry of entries) {
88
338
  if (!entry.isDirectory()) continue;
89
339
  if (entry.name.startsWith('.') && entry.name !== '.git') continue;
90
340
  if (entry.name === 'node_modules') continue;
91
-
92
341
  const fullPath = join(basePath, entry.name);
93
- try {
94
- if (lstatSync(fullPath).isSymbolicLink()) continue;
95
- } catch { continue; }
96
-
342
+ try { if (lstatSync(fullPath).isSymbolicLink()) continue; } catch { continue; }
97
343
  if (entry.name === '.git') {
98
344
  repos.push(dirname(fullPath));
99
345
  } else {
@@ -104,7 +350,6 @@ function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
104
350
  return repos;
105
351
  }
106
352
 
107
- // .env ファイルを検索
108
353
  function findEnvFiles(repoPath) {
109
354
  const envFiles = [];
110
355
  for (const filename of ['.env', '.dev.vars', '.env.local', '.env.production']) {
@@ -121,7 +366,6 @@ function findEnvFiles(repoPath) {
121
366
  return envFiles;
122
367
  }
123
368
 
124
- // .env ファイルをパース
125
369
  function parseEnvFile(content) {
126
370
  const entries = [];
127
371
  const multilineRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*`([\s\S]*?)`/gm;
@@ -141,19 +385,41 @@ function parseEnvFile(content) {
141
385
  return entries;
142
386
  }
143
387
 
144
- // 値の比較
145
388
  function compareValues(a, b) {
146
389
  return a.trim().replace(/\r\n/g, '\n') === b.trim().replace(/\r\n/g, '\n');
147
390
  }
148
391
 
149
- // CLI モード
392
+ // キャッシュ管理
393
+ function getCachePath() {
394
+ return join(homedir(), '.secrets-manager-cache.json');
395
+ }
396
+
397
+ function readCache() {
398
+ const cachePath = getCachePath();
399
+ if (existsSync(cachePath)) {
400
+ try { return JSON.parse(readFileSync(cachePath, 'utf-8')); } catch { return {}; }
401
+ }
402
+ return {};
403
+ }
404
+
405
+ function writeCache(cache) {
406
+ writeFileSync(getCachePath(), JSON.stringify(cache, null, 2));
407
+ }
408
+
409
+ function hashContent(content) {
410
+ return createHash('md5').update(content).digest('hex');
411
+ }
412
+
413
+ // ============================================================
414
+ // CLI メイン
415
+ // ============================================================
150
416
  async function runCli(args) {
151
417
  const parsed = parseArgs(args);
152
418
  const command = parsed.positional[0];
153
419
  const config = getConfig();
154
420
  const targetEnv = parsed.env || config.defaultEnvironment;
155
421
 
156
- if (!config.centralProject && command !== "init") {
422
+ if (!config.driveFolderId && command && command !== "init" && command !== "pre-commit" && command !== "hook") {
157
423
  console.error("エラー: 先に init を実行してください");
158
424
  process.exit(1);
159
425
  }
@@ -161,87 +427,155 @@ async function runCli(args) {
161
427
  try {
162
428
  switch (command) {
163
429
  case "init": {
164
- const projectId = parsed.positional[1];
430
+ const driveFolderId = parsed.positional[1];
165
431
  const defaultEnv = parsed.env || "dev";
166
- if (!projectId) {
167
- console.error("使い方: gcloud-secrets init <project-id> [--env <default-env>]");
432
+ const clientId = parsed.clientId || config.googleClientId;
433
+ const clientSecret = parsed.clientSecret || config.googleClientSecret;
434
+
435
+ if (!clientId || !clientSecret) {
436
+ console.error("使い方: gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>] [--age-pub <key>] [--age-key <path>]");
168
437
  process.exit(1);
169
438
  }
170
- const configFile = `${homedir()}/.secrets-manager.conf`;
171
- const configContent = `SECRETS_CENTRAL_PROJECT=${projectId}\nDEFAULT_ENVIRONMENT=${defaultEnv}\n`;
172
- writeFileSync(configFile, configContent);
173
- console.log(`設定完了: ${projectId} (デフォルト環境: ${defaultEnv})`);
439
+
440
+ // age チェック
441
+ checkAgeInstalled();
442
+
443
+ // age 鍵の設定
444
+ let ageKeyPath = parsed.ageKey || config.ageKeyPath;
445
+ let agePublicKey = parsed.agePub || config.agePublicKey;
446
+
447
+ if (!existsSync(ageKeyPath)) {
448
+ const ageDir = dirname(ageKeyPath);
449
+ if (!existsSync(ageDir)) mkdirSync(ageDir, { recursive: true });
450
+ console.log(`age 鍵を生成中: ${ageKeyPath}`);
451
+ execFileSync('age-keygen', ['-o', ageKeyPath]);
452
+ }
453
+
454
+ if (!agePublicKey) {
455
+ agePublicKey = getAgePublicKeyFromFile(ageKeyPath);
456
+ }
457
+
458
+ // OAuth 認証テスト
459
+ const tempConfig = { ...config, googleClientId: clientId, googleClientSecret: clientSecret };
460
+ const drive = await getDriveClient(tempConfig);
461
+
462
+ let folderId = driveFolderId;
463
+ if (!folderId) {
464
+ // ルートフォルダ作成
465
+ console.log('Drive にルートフォルダ "gcloud-secrets" を作成中...');
466
+ const res = await drive.files.create({
467
+ requestBody: {
468
+ name: 'gcloud-secrets',
469
+ mimeType: 'application/vnd.google-apps.folder',
470
+ },
471
+ fields: 'id, name',
472
+ });
473
+ folderId = res.data.id;
474
+ console.log(`フォルダ作成完了: ${res.data.name} (${folderId})`);
475
+ } else {
476
+ // 既存フォルダの検証
477
+ try {
478
+ const res = await drive.files.get({ fileId: folderId, fields: 'id, name' });
479
+ console.log(`Drive フォルダ確認: ${res.data.name} (${folderId})`);
480
+ } catch {
481
+ console.error(`エラー: Drive フォルダ ID "${folderId}" にアクセスできません`);
482
+ process.exit(1);
483
+ }
484
+ }
485
+
486
+ // 設定保存
487
+ writeConfig({
488
+ DRIVE_FOLDER_ID: folderId,
489
+ DEFAULT_ENVIRONMENT: defaultEnv,
490
+ AGE_PUBLIC_KEY: agePublicKey,
491
+ AGE_KEY_PATH: ageKeyPath,
492
+ GOOGLE_CLIENT_ID: clientId,
493
+ GOOGLE_CLIENT_SECRET: clientSecret,
494
+ });
495
+
496
+ console.log(`設定完了:
497
+ Drive フォルダ: ${folderId}
498
+ デフォルト環境: ${defaultEnv}
499
+ age 公開鍵: ${agePublicKey}
500
+ age 秘密鍵: ${ageKeyPath}`);
174
501
  break;
175
502
  }
176
503
 
177
504
  case "list": {
178
505
  const folder = parsed.positional[1];
179
- const parent = `projects/${config.centralProject}`;
180
- const [secrets] = await client.listSecrets({ parent });
506
+ const drive = await getDriveClient(config);
181
507
 
182
508
  if (!folder) {
183
- // フォルダ一覧 (環境ごとにグループ化)
184
- const folderEnvs = new Map();
185
- for (const secret of secrets) {
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);
192
- }
509
+ // フォルダ一覧
510
+ const folders = await listDriveFolders(drive, config.driveFolderId);
511
+ if (folders.length === 0) {
512
+ console.log("シークレットが登録されていません");
513
+ break;
193
514
  }
515
+
194
516
  console.log("フォルダ一覧:");
195
- for (const [f, envs] of folderEnvs) {
196
- const envList = Array.from(envs).sort().join(', ');
197
- console.log(` ${f} [${envList}]`);
517
+ for (const f of folders) {
518
+ const files = await listEnvAgeFiles(drive, f.id);
519
+ const envs = files
520
+ .map(file => file.name.match(/^(.+)\.env\.age$/))
521
+ .filter(Boolean)
522
+ .map(m => m[1])
523
+ .sort();
524
+ console.log(` ${f.name} [${envs.join(', ')}]`);
198
525
  }
199
526
  } else {
200
- // 特定フォルダのシークレット一覧 (環境でフィルタ)
201
- console.log(`${folder} (${targetEnv}) のシークレット:`);
202
- for (const secret of secrets) {
203
- const [secretData] = await client.getSecret({ name: secret.name });
204
- const secretEnv = secretData.labels?.environment || null;
205
- if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
206
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
207
- console.log(` ${key}`);
208
- }
527
+ // 特定フォルダのキー一覧
528
+ checkAgeInstalled();
529
+ const normalizedFolder = normalizeFolder(folder);
530
+ const folderObj = await findFolder(drive, config.driveFolderId, normalizedFolder);
531
+ if (!folderObj) {
532
+ console.error(`フォルダが見つかりません: ${normalizedFolder}`);
533
+ process.exit(1);
534
+ }
535
+
536
+ const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
537
+ if (!file) {
538
+ console.error(`${normalizedFolder} (${targetEnv}) にシークレットが見つかりません`);
539
+ break;
540
+ }
541
+
542
+ const encrypted = await downloadFile(drive, file.id);
543
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
544
+ const entries = parseEnvFile(decrypted);
545
+
546
+ console.log(`${normalizedFolder} (${targetEnv}) のシークレット:`);
547
+ for (const entry of entries) {
548
+ console.log(` ${entry.key}`);
209
549
  }
210
550
  }
211
551
  break;
212
552
  }
213
553
 
214
554
  case "pull": {
555
+ checkAgeInstalled();
215
556
  const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
216
- const parent = `projects/${config.centralProject}`;
217
- const [secrets] = await client.listSecrets({ parent });
218
-
219
- const envLines = [];
220
- for (const secret of secrets) {
221
- const [secretData] = await client.getSecret({ name: secret.name });
222
- const secretEnv = secretData.labels?.environment || null;
223
- if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
224
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
225
- const [version] = await client.accessSecretVersion({
226
- name: `${secret.name}/versions/latest`,
227
- });
228
- const value = version.payload.data.toString("utf-8");
229
- if (value.includes("\n")) {
230
- envLines.push(`${key}=\`${value}\``);
231
- } else {
232
- envLines.push(`${key}=${value}`);
233
- }
234
- }
557
+ const drive = await getDriveClient(config);
558
+
559
+ const folderObj = await findFolder(drive, config.driveFolderId, folder);
560
+ if (!folderObj) {
561
+ console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
562
+ break;
235
563
  }
236
- if (envLines.length === 0) {
564
+
565
+ const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
566
+ if (!file) {
237
567
  console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
238
- } else {
239
- console.log(envLines.join("\n"));
568
+ break;
240
569
  }
570
+
571
+ const encrypted = await downloadFile(drive, file.id);
572
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
573
+ console.log(decrypted.trimEnd());
241
574
  break;
242
575
  }
243
576
 
244
577
  case "push": {
578
+ checkAgeInstalled();
245
579
  const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
246
580
  const envFile = parsed.positional[2] || ".env";
247
581
 
@@ -251,67 +585,43 @@ async function runCli(args) {
251
585
  }
252
586
 
253
587
  const content = readFileSync(envFile, "utf-8");
254
- const lines = content.split("\n");
255
- const parent = `projects/${config.centralProject}`;
256
- const labels = { folder, environment: targetEnv };
257
- let count = 0;
258
-
259
- for (const line of lines) {
260
- if (!line.trim() || line.startsWith("#")) continue;
261
- const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/i);
262
- if (match) {
263
- const [, key, value] = match;
264
- const secretId = makeSecretName(folder, key, targetEnv);
265
- const secretName = `${parent}/secrets/${secretId}`;
266
-
267
- try {
268
- await client.getSecret({ name: secretName });
269
- // 既存シークレットのラベルも更新
270
- await client.updateSecret({
271
- secret: { name: secretName, labels },
272
- updateMask: { paths: ['labels'] }
273
- });
274
- await client.addSecretVersion({
275
- parent: secretName,
276
- payload: { data: Buffer.from(value) },
277
- });
278
- } catch {
279
- await client.createSecret({
280
- parent,
281
- secretId,
282
- secret: { replication: { automatic: {} }, labels },
283
- });
284
- await client.addSecretVersion({
285
- parent: secretName,
286
- payload: { data: Buffer.from(value) },
287
- });
288
- }
289
- count++;
290
- }
588
+ const entries = parseEnvFile(content);
589
+ if (entries.length === 0) {
590
+ console.error("有効なシークレットが見つかりません");
591
+ process.exit(1);
291
592
  }
292
- console.log(`${count} 件のシークレットをアップロードしました (${folder}/${targetEnv})`);
593
+
594
+ const drive = await getDriveClient(config);
595
+ const folderObj = await getOrCreateFolder(drive, config.driveFolderId, folder);
596
+
597
+ const encrypted = ageEncrypt(content, config.agePublicKey);
598
+ const fileName = `${targetEnv}.env.age`;
599
+ await uploadFile(drive, folderObj.id, fileName, encrypted);
600
+
601
+ console.log(`${entries.length} 件のシークレットをアップロードしました (${folder}/${targetEnv})`);
293
602
  break;
294
603
  }
295
604
 
296
605
  case "scan": {
606
+ checkAgeInstalled();
297
607
  const basePath = parsed.positional[1] || homedir();
298
- const filterEnv = parsed.env; // null の場合は全環境を表示
608
+ const filterEnv = parsed.env;
299
609
  const repos = findGitRepositories(basePath, 5);
300
- const parent = `projects/${config.centralProject}`;
301
- const [allSecrets] = await client.listSecrets({ parent });
302
-
303
- // フォルダ+環境ごとにグループ化
304
- const secretsByFolderEnv = new Map();
305
- for (const secret of allSecrets) {
306
- const [secretData] = await client.getSecret({ name: secret.name });
307
- const f = secretData.labels?.folder;
308
- const e = secretData.labels?.environment || null;
309
- if (f) {
310
- const key = `${f}|${e || ''}`;
311
- if (!secretsByFolderEnv.has(key)) secretsByFolderEnv.set(key, []);
312
- secretsByFolderEnv.get(key).push({ secret, env: e });
313
- }
314
- }
610
+ const drive = await getDriveClient(config);
611
+
612
+ // リモートの全ファイルを取得
613
+ const remoteFiles = await listAllEnvFiles(drive, config.driveFolderId);
614
+
615
+ // リモートデータをダウンロード・復号(並列)
616
+ const remoteData = new Map(); // key: "folder|env" -> parsed entries
617
+ await Promise.all(remoteFiles.map(async (rf) => {
618
+ try {
619
+ const encrypted = await downloadFile(drive, rf.fileId);
620
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
621
+ const entries = parseEnvFile(decrypted);
622
+ remoteData.set(`${rf.folder}|${rf.env}`, entries);
623
+ } catch { }
624
+ }));
315
625
 
316
626
  const results = [];
317
627
  let syncedCount = 0, diffCount = 0, newCount = 0;
@@ -331,56 +641,52 @@ async function runCli(args) {
331
641
  const localEntries = parseEnvFile(content);
332
642
  if (localEntries.length === 0) continue;
333
643
 
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
- ))];
644
+ // チェック対象の環境を決定
645
+ const envsToCheck = filterEnv
646
+ ? [filterEnv]
647
+ : [...new Set(
648
+ Array.from(remoteData.keys())
649
+ .filter(k => k.startsWith(normalizedFolder + '|'))
650
+ .map(k => k.split('|')[1])
651
+ )];
652
+
653
+ if (envsToCheck.length === 0) {
654
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: filterEnv || "(default)", keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
655
+ newCount++;
656
+ continue;
657
+ }
341
658
 
342
659
  for (const checkEnv of envsToCheck) {
343
- const mapKey = `${normalizedFolder}|${checkEnv || ''}`;
344
- const folderSecrets = secretsByFolderEnv.get(mapKey) || [];
345
- const envLabel = checkEnv || "(default)";
660
+ const mapKey = `${normalizedFolder}|${checkEnv}`;
661
+ const remoteEntries = remoteData.get(mapKey);
346
662
 
347
- if (folderSecrets.length === 0) {
348
- results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
663
+ if (!remoteEntries) {
664
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
349
665
  newCount++;
350
666
  continue;
351
667
  }
352
668
 
353
- // リモート値取得・比較
669
+ // 比較
670
+ const remoteMap = new Map(remoteEntries.map(e => [e.key, e.value]));
354
671
  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
- }
366
672
 
367
673
  for (const entry of localEntries) {
368
- if (!remoteKeys.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key) || '')) {
674
+ if (!remoteMap.has(entry.key) || !compareValues(entry.value, remoteMap.get(entry.key))) {
369
675
  hasDiff = true;
370
676
  break;
371
677
  }
372
678
  }
373
679
  if (!hasDiff) {
374
- for (const key of remoteKeys) {
375
- if (!localEntries.find(e => e.key === key)) { hasDiff = true; break; }
680
+ for (const re of remoteEntries) {
681
+ if (!localEntries.find(e => e.key === re.key)) { hasDiff = true; break; }
376
682
  }
377
683
  }
378
684
 
379
685
  if (hasDiff) {
380
- results.push({ status: "DIFF", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
686
+ results.push({ status: "DIFF", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
381
687
  diffCount++;
382
688
  } else {
383
- results.push({ status: "OK", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
689
+ results.push({ status: "OK", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
384
690
  syncedCount++;
385
691
  }
386
692
  }
@@ -388,7 +694,7 @@ async function runCli(args) {
388
694
  }
389
695
 
390
696
  const envSuffix = filterEnv ? ` (${filterEnv})` : "";
391
- console.log(`=== Secret Manager 同期状況${envSuffix} ===\n`);
697
+ console.log(`=== シークレット同期状況${envSuffix} ===\n`);
392
698
  if (results.length === 0) {
393
699
  console.log(".env / .dev.vars ファイルが見つかりませんでした");
394
700
  } else {
@@ -408,6 +714,97 @@ async function runCli(args) {
408
714
  break;
409
715
  }
410
716
 
717
+ case "pre-commit": {
718
+ // config なし → サイレント exit
719
+ if (!config.driveFolderId || !config.googleClientId) process.exit(0);
720
+ // OAuth トークンなし → サイレント exit (対話的認証を避ける)
721
+ if (!existsSync(getTokenPath())) process.exit(0);
722
+ // age なし → サイレント exit
723
+ try { execFileSync('age', ['--version'], { stdio: 'ignore' }); } catch { process.exit(0); }
724
+
725
+ const cwd = process.cwd();
726
+ const folder = normalizeFolder(basename(resolve(cwd)));
727
+ const envFiles = findEnvFiles(cwd);
728
+ if (envFiles.length === 0) process.exit(0);
729
+
730
+ const cache = readCache();
731
+ let totalPushed = 0;
732
+
733
+ let drive;
734
+ try { drive = await getDriveClient(config); } catch { process.exit(0); }
735
+
736
+ for (const envFile of envFiles) {
737
+ let content;
738
+ try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
739
+ if (!content.trim()) continue;
740
+
741
+ const currentHash = hashContent(content);
742
+ const cacheKey = envFile.path;
743
+
744
+ // キャッシュヒット → スキップ (0 API コール)
745
+ if (cache[cacheKey] && cache[cacheKey].hash === currentHash) {
746
+ console.log(`✓ ${envFile.filename} synced`);
747
+ continue;
748
+ }
749
+
750
+ const localEntries = parseEnvFile(content);
751
+ if (localEntries.length === 0) continue;
752
+
753
+ try {
754
+ // リモートファイルを取得して比較
755
+ const folderObj = await findFolder(drive, config.driveFolderId, folder);
756
+ let needsPush = true;
757
+
758
+ if (folderObj) {
759
+ const remoteFile = await findEnvAgeFile(drive, folderObj.id, targetEnv);
760
+ if (remoteFile) {
761
+ const encrypted = await downloadFile(drive, remoteFile.id);
762
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
763
+ const remoteEntries = parseEnvFile(decrypted);
764
+ const remoteMap = new Map(remoteEntries.map(e => [e.key, e.value]));
765
+
766
+ // 差分チェック
767
+ needsPush = false;
768
+ for (const entry of localEntries) {
769
+ if (!remoteMap.has(entry.key) || !compareValues(entry.value, remoteMap.get(entry.key))) {
770
+ needsPush = true;
771
+ break;
772
+ }
773
+ }
774
+ if (!needsPush) {
775
+ for (const re of remoteEntries) {
776
+ if (!localEntries.find(e => e.key === re.key)) { needsPush = true; break; }
777
+ }
778
+ }
779
+ }
780
+ }
781
+
782
+ if (needsPush) {
783
+ const targetFolder = await getOrCreateFolder(drive, config.driveFolderId, folder);
784
+ const encryptedContent = ageEncrypt(content, config.agePublicKey);
785
+ await uploadFile(drive, targetFolder.id, `${targetEnv}.env.age`, encryptedContent);
786
+ console.log(`↑ ${envFile.filename}: pushed (${folder}/${targetEnv})`);
787
+ totalPushed++;
788
+ } else {
789
+ console.log(`✓ ${envFile.filename} synced`);
790
+ }
791
+
792
+ // キャッシュ更新
793
+ cache[cacheKey] = {
794
+ hash: currentHash,
795
+ folder,
796
+ env: targetEnv,
797
+ syncedAt: new Date().toISOString()
798
+ };
799
+ } catch (error) {
800
+ console.log(`⚠ ${envFile.filename}: sync skipped (${error.message})`);
801
+ }
802
+ }
803
+
804
+ try { writeCache(cache); } catch { }
805
+ process.exit(0);
806
+ }
807
+
411
808
  case "search": {
412
809
  const keyword = parsed.positional[1];
413
810
  if (!keyword) {
@@ -415,42 +812,35 @@ async function runCli(args) {
415
812
  process.exit(1);
416
813
  }
417
814
 
815
+ checkAgeInstalled();
418
816
  const filterEnv = parsed.env;
419
- const parent = `projects/${config.centralProject}`;
420
- const [secrets] = await client.listSecrets({ parent });
817
+ const drive = await getDriveClient(config);
818
+ const allFiles = await listAllEnvFiles(drive, config.driveFolderId);
819
+
820
+ // 環境フィルタ
821
+ const targetFiles = filterEnv ? allFiles.filter(f => f.env === filterEnv) : allFiles;
421
822
 
422
823
  console.log(`Searching for: "${keyword}"`);
423
824
  if (filterEnv) console.log(` 環境: ${filterEnv}`);
424
- console.log(`\nScanning ${secrets.length} secrets...\n`);
825
+ console.log(`\nScanning ${targetFiles.length} files...\n`);
425
826
 
426
827
  const results = await Promise.all(
427
- secrets.map(async (secret) => {
828
+ targetFiles.map(async (rf) => {
428
829
  try {
429
- const [secretData] = await client.getSecret({ name: secret.name });
430
- const folder = secretData.labels?.folder;
431
- const env = secretData.labels?.environment || "(default)";
432
-
433
- // 環境フィルタ
434
- if (filterEnv && secretData.labels?.environment !== filterEnv) return null;
435
-
436
- // 値を取得してキーワード検索
437
- const [version] = await client.accessSecretVersion({
438
- name: `${secret.name}/versions/latest`,
439
- });
440
- const value = version.payload.data.toString("utf-8");
441
- if (value.includes(keyword)) {
442
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
443
- return { folder, env, key };
444
- }
830
+ const encrypted = await downloadFile(drive, rf.fileId);
831
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
832
+ const entries = parseEnvFile(decrypted);
833
+ return entries
834
+ .filter(e => e.value.includes(keyword))
835
+ .map(e => ({ folder: rf.folder, env: rf.env, key: e.key }));
445
836
  } catch {
446
- // バージョンがない場合はスキップ
837
+ return [];
447
838
  }
448
- return null;
449
839
  })
450
840
  );
451
841
 
452
- const matches = results.filter((r) => r !== null);
453
- const folders = new Set(matches.map((m) => m.folder));
842
+ const matches = results.flat();
843
+ const folders = new Set(matches.map(m => m.folder));
454
844
 
455
845
  if (matches.length === 0) {
456
846
  console.log("No matches found");
@@ -463,16 +853,169 @@ async function runCli(args) {
463
853
  break;
464
854
  }
465
855
 
856
+ case "key": {
857
+ const subcommand = parsed.positional[1];
858
+
859
+ if (subcommand === "backup") {
860
+ checkAgeInstalled();
861
+ if (!existsSync(config.ageKeyPath)) {
862
+ console.error(`エラー: age 秘密鍵が見つかりません: ${config.ageKeyPath}`);
863
+ process.exit(1);
864
+ }
865
+
866
+ // gpg で暗号化
867
+ try { execFileSync('gpg', ['--version'], { stdio: 'ignore' }); } catch {
868
+ console.error('エラー: gpg がインストールされていません');
869
+ process.exit(1);
870
+ }
871
+
872
+ const tmpGpg = join(homedir(), '.age', 'age-key.gpg');
873
+ console.log('gpg パスワードを入力してください(復元時に必要)...');
874
+ execSync(`gpg --symmetric --cipher-algo AES256 -o "${tmpGpg}" "${config.ageKeyPath}"`, { stdio: 'inherit' });
875
+
876
+ // Drive にアップロード
877
+ const drive = await getDriveClient(config);
878
+ const gpgContent = readFileSync(tmpGpg);
879
+ await uploadFile(drive, config.driveFolderId, 'age-key.gpg', gpgContent);
880
+ rmSync(tmpGpg);
881
+
882
+ console.log('age 秘密鍵を暗号化して Drive にバックアップしました (age-key.gpg)');
883
+
884
+ } else if (subcommand === "restore") {
885
+ // Drive からダウンロード
886
+ const drive = await getDriveClient(config);
887
+ const res = await drive.files.list({
888
+ q: `'${escapeQuery(config.driveFolderId)}' in parents and name = 'age-key.gpg' and trashed = false`,
889
+ fields: 'files(id)',
890
+ pageSize: 1,
891
+ });
892
+ const file = (res.data.files || [])[0];
893
+ if (!file) {
894
+ console.error('エラー: Drive に age-key.gpg が見つかりません');
895
+ process.exit(1);
896
+ }
897
+
898
+ const gpgData = await downloadFile(drive, file.id);
899
+ const tmpGpg = join(homedir(), '.age', 'age-key.gpg');
900
+ const ageDir = dirname(config.ageKeyPath);
901
+ if (!existsSync(ageDir)) mkdirSync(ageDir, { recursive: true });
902
+
903
+ writeFileSync(tmpGpg, gpgData);
904
+ console.log('gpg パスワードを入力してください...');
905
+ execSync(`gpg --decrypt -o "${config.ageKeyPath}" "${tmpGpg}"`, { stdio: 'inherit' });
906
+ rmSync(tmpGpg);
907
+
908
+ // 公開鍵も表示
909
+ const pubKey = getAgePublicKeyFromFile(config.ageKeyPath);
910
+ console.log(`age 秘密鍵を復元しました: ${config.ageKeyPath}`);
911
+ console.log(`公開鍵: ${pubKey}`);
912
+
913
+ } else {
914
+ console.log(`使い方:
915
+ gcloud-secrets key backup age 秘密鍵を gpg 暗号化して Drive にバックアップ
916
+ gcloud-secrets key restore Drive から age 秘密鍵を復元`);
917
+ }
918
+ break;
919
+ }
920
+
921
+ case "hook": {
922
+ const subcommand = parsed.positional[1];
923
+
924
+ if (subcommand === "install") {
925
+ const hooksDir = join(homedir(), '.git-hooks');
926
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
927
+
928
+ const hookTypes = [
929
+ 'applypatch-msg', 'pre-applypatch', 'post-applypatch',
930
+ 'pre-commit', 'prepare-commit-msg', 'commit-msg', 'post-commit',
931
+ 'pre-rebase', 'post-checkout', 'post-merge',
932
+ 'pre-push', 'pre-auto-gc', 'post-rewrite'
933
+ ];
934
+
935
+ for (const hookType of hookTypes) {
936
+ const hookPath = join(hooksDir, hookType);
937
+ let extraLogic = '';
938
+
939
+ if (hookType === 'pre-commit') {
940
+ extraLogic = `
941
+ # gcloud-secrets: auto-sync .env to Drive
942
+ if command -v gcloud-secrets >/dev/null 2>&1; then
943
+ gcloud-secrets pre-commit
944
+ fi
945
+ `;
946
+ }
947
+
948
+ const hookScript = `#!/bin/sh
949
+ # Global git hook: ${hookType}
950
+ # Installed by gcloud-secrets
951
+ ${extraLogic}
952
+ # Forward to .husky/${hookType} if it exists
953
+ if [ -f "$(pwd)/.husky/${hookType}" ]; then
954
+ "$(pwd)/.husky/${hookType}" "$@"
955
+ exit_code=$?
956
+ if [ $exit_code -ne 0 ]; then
957
+ exit $exit_code
958
+ fi
959
+ fi
960
+
961
+ # Forward to .git/hooks/${hookType} if it exists
962
+ GIT_DIR_HOOKS="$(git rev-parse --git-dir 2>/dev/null)/hooks/${hookType}"
963
+ if [ -f "$GIT_DIR_HOOKS" ] && [ -x "$GIT_DIR_HOOKS" ]; then
964
+ "$GIT_DIR_HOOKS" "$@"
965
+ exit $?
966
+ fi
967
+
968
+ exit 0
969
+ `;
970
+ writeFileSync(hookPath, hookScript);
971
+ chmodSync(hookPath, '755');
972
+ }
973
+
974
+ execSync('git config --global core.hooksPath ~/.git-hooks');
975
+ console.log(`グローバル git hooks をインストールしました:
976
+ フックディレクトリ: ${hooksDir}
977
+ 対象: pre-commit (gcloud-secrets auto-sync)
978
+ 互換性: .husky/ と .git/hooks/ にフォワード
979
+
980
+ 全リポジトリの git commit で .env が自動同期されます。`);
981
+
982
+ } else if (subcommand === "uninstall") {
983
+ try { execSync('git config --global --unset core.hooksPath', { stdio: 'ignore' }); } catch { }
984
+ const hooksDir = join(homedir(), '.git-hooks');
985
+ if (existsSync(hooksDir)) {
986
+ try {
987
+ rmSync(hooksDir, { recursive: true, force: true });
988
+ } catch (error) {
989
+ console.log(`⚠ ${hooksDir} の削除に失敗: ${error.message}`);
990
+ console.log(`手動で削除してください: rm -rf ${hooksDir}`);
991
+ }
992
+ }
993
+ console.log(`グローバル git hooks をアンインストールしました。`);
994
+
995
+ } else {
996
+ console.log(`使い方:
997
+ gcloud-secrets hook install グローバル pre-commit hook をインストール
998
+ gcloud-secrets hook uninstall グローバル pre-commit hook をアンインストール`);
999
+ }
1000
+ break;
1001
+ }
1002
+
466
1003
  default:
467
- console.log(`gcloud-secrets - GCP Secret Manager CLI
1004
+ console.log(`gcloud-secrets - シークレット管理 CLI (Google Drive + age 暗号化)
468
1005
 
469
1006
  使い方:
470
- gcloud-secrets init <project-id> [--env <default>] 中央プロジェクトを設定
471
- gcloud-secrets list [folder] [--env <env>] 一覧表示
472
- gcloud-secrets pull [folder] [--env <env>] シークレットを取得
473
- gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
474
- gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
475
- gcloud-secrets search <keyword> [--env <env>] 値から逆引き検索
1007
+ gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>]
1008
+ 初期設定 (OAuth + age 鍵 + Drive フォルダ)
1009
+ gcloud-secrets list [folder] [--env <env>] 一覧表示
1010
+ gcloud-secrets pull [folder] [--env <env>] シークレットを取得
1011
+ gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
1012
+ gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
1013
+ gcloud-secrets search <keyword> [--env <env>] 値から逆引き検索
1014
+ gcloud-secrets pre-commit .env 自動同期 (git hook 用)
1015
+ gcloud-secrets key backup age 秘密鍵を暗号化して Drive にバックアップ
1016
+ gcloud-secrets key restore Drive から age 秘密鍵を復元
1017
+ gcloud-secrets hook install グローバル git hook インストール
1018
+ gcloud-secrets hook uninstall グローバル git hook アンインストール
476
1019
 
477
1020
  オプション:
478
1021
  --env, -e <env> 環境を指定 (dev, staging, prod など)