@yhonda/gcloud-secrets 2.0.12 → 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 (3) hide show
  1. package/cli.js +606 -292
  2. package/package.json +6 -5
  3. package/skills/secrets.md +17 -12
package/cli.js CHANGED
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
4
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, mkdirSync, chmodSync, rmSync } from "fs";
5
4
  import { createHash } from "crypto";
6
5
  import { basename, join, dirname, resolve } from "path";
7
6
  import { homedir } from "os";
8
- import { execSync } from "child_process";
9
-
10
- // SDK クライアント初期化
11
- const client = new SecretManagerServiceClient();
12
-
13
- // 引数パース (--env / -e オプション抽出)
7
+ import { execSync, execFileSync } from "child_process";
8
+ import { createServer } from "http";
9
+ import { google } from "googleapis";
10
+ import { Readable } from "stream";
11
+
12
+ // ============================================================
13
+ // 引数パース
14
+ // ============================================================
14
15
  function parseArgs(args) {
15
- const result = { positional: [], env: null };
16
+ const result = { positional: [], env: null, ageKey: null, agePub: null, clientId: null, clientSecret: null };
16
17
  for (let i = 0; i < args.length; i++) {
17
18
  if (args[i] === '--env' || args[i] === '-e') {
18
- result.env = args[i + 1];
19
- i++;
19
+ result.env = args[i + 1]; i++;
20
20
  } else if (args[i].startsWith('--env=')) {
21
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++;
22
30
  } else {
23
31
  result.positional.push(args[i]);
24
32
  }
@@ -26,75 +34,312 @@ function parseArgs(args) {
26
34
  return result;
27
35
  }
28
36
 
29
- // 設定読み込み
37
+ // ============================================================
38
+ // 設定
39
+ // ============================================================
30
40
  function getConfig() {
31
41
  const configFile = `${homedir()}/.secrets-manager.conf`;
32
42
  const config = {
33
- centralProject: process.env.SECRETS_CENTRAL_PROJECT || "",
34
- 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 || "",
35
49
  };
36
50
  if (existsSync(configFile)) {
37
51
  const content = readFileSync(configFile, "utf-8");
38
- const projectMatch = content.match(/SECRETS_CENTRAL_PROJECT=(.+)/);
39
- if (projectMatch) {
40
- config.centralProject = projectMatch[1].trim();
41
- }
42
- const envMatch = content.match(/DEFAULT_ENVIRONMENT=(.+)/);
43
- if (envMatch) {
44
- 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();
45
62
  }
46
63
  }
47
64
  return config;
48
65
  }
49
66
 
50
- // シークレット名生成 (環境対応)
51
- function makeSecretName(folder, key, env = null) {
52
- if (env) {
53
- 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);
54
86
  }
55
- return `${folder}_${key}`;
56
87
  }
57
88
 
58
- // シークレット名からキーと環境を抽出
59
- function getKeyFromSecret(secretName, folderName) {
60
- const prefix = folderName + "_";
61
- if (!secretName.startsWith(prefix)) {
62
- 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);
63
120
  }
64
- const rest = secretName.slice(prefix.length);
65
- const parts = rest.split("_");
66
- // 2つ以上のパートがあり、最初がアルファベット小文字のみなら環境名と判断
67
- if (parts.length >= 2 && /^[a-z]+$/.test(parts[0])) {
68
- 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;
139
+ }
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}`);
69
154
  }
70
- return { key: rest, env: null };
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, "\\'");
71
203
  }
72
204
 
73
- // フォルダ名を正規化 (camelCase kebab-case)
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 || [];
255
+ }
256
+
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
+ // ============================================================
74
325
  function normalizeFolder(name) {
75
326
  return name
76
- .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase → kebab-case
327
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
77
328
  .toLowerCase()
78
329
  .replace(/[^a-z0-9_-]/g, '-');
79
330
  }
80
331
 
81
- // Git リポジトリを再帰的に検索
82
332
  function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
83
333
  const repos = [];
84
334
  if (currentDepth > maxDepth) return repos;
85
-
86
335
  try {
87
336
  const entries = readdirSync(basePath, { withFileTypes: true });
88
337
  for (const entry of entries) {
89
338
  if (!entry.isDirectory()) continue;
90
339
  if (entry.name.startsWith('.') && entry.name !== '.git') continue;
91
340
  if (entry.name === 'node_modules') continue;
92
-
93
341
  const fullPath = join(basePath, entry.name);
94
- try {
95
- if (lstatSync(fullPath).isSymbolicLink()) continue;
96
- } catch { continue; }
97
-
342
+ try { if (lstatSync(fullPath).isSymbolicLink()) continue; } catch { continue; }
98
343
  if (entry.name === '.git') {
99
344
  repos.push(dirname(fullPath));
100
345
  } else {
@@ -105,7 +350,6 @@ function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
105
350
  return repos;
106
351
  }
107
352
 
108
- // .env ファイルを検索
109
353
  function findEnvFiles(repoPath) {
110
354
  const envFiles = [];
111
355
  for (const filename of ['.env', '.dev.vars', '.env.local', '.env.production']) {
@@ -122,7 +366,6 @@ function findEnvFiles(repoPath) {
122
366
  return envFiles;
123
367
  }
124
368
 
125
- // .env ファイルをパース
126
369
  function parseEnvFile(content) {
127
370
  const entries = [];
128
371
  const multilineRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*`([\s\S]*?)`/gm;
@@ -142,7 +385,6 @@ function parseEnvFile(content) {
142
385
  return entries;
143
386
  }
144
387
 
145
- // 値の比較
146
388
  function compareValues(a, b) {
147
389
  return a.trim().replace(/\r\n/g, '\n') === b.trim().replace(/\r\n/g, '\n');
148
390
  }
@@ -168,14 +410,16 @@ function hashContent(content) {
168
410
  return createHash('md5').update(content).digest('hex');
169
411
  }
170
412
 
171
- // CLI モード
413
+ // ============================================================
414
+ // CLI メイン
415
+ // ============================================================
172
416
  async function runCli(args) {
173
417
  const parsed = parseArgs(args);
174
418
  const command = parsed.positional[0];
175
419
  const config = getConfig();
176
420
  const targetEnv = parsed.env || config.defaultEnvironment;
177
421
 
178
- if (!config.centralProject && command !== "init" && command !== "pre-commit" && command !== "hook") {
422
+ if (!config.driveFolderId && command && command !== "init" && command !== "pre-commit" && command !== "hook") {
179
423
  console.error("エラー: 先に init を実行してください");
180
424
  process.exit(1);
181
425
  }
@@ -183,87 +427,155 @@ async function runCli(args) {
183
427
  try {
184
428
  switch (command) {
185
429
  case "init": {
186
- const projectId = parsed.positional[1];
430
+ const driveFolderId = parsed.positional[1];
187
431
  const defaultEnv = parsed.env || "dev";
188
- if (!projectId) {
189
- 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>]");
190
437
  process.exit(1);
191
438
  }
192
- const configFile = `${homedir()}/.secrets-manager.conf`;
193
- const configContent = `SECRETS_CENTRAL_PROJECT=${projectId}\nDEFAULT_ENVIRONMENT=${defaultEnv}\n`;
194
- writeFileSync(configFile, configContent);
195
- 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}`);
196
501
  break;
197
502
  }
198
503
 
199
504
  case "list": {
200
505
  const folder = parsed.positional[1];
201
- const parent = `projects/${config.centralProject}`;
202
- const [secrets] = await client.listSecrets({ parent });
506
+ const drive = await getDriveClient(config);
203
507
 
204
508
  if (!folder) {
205
- // フォルダ一覧 (環境ごとにグループ化)
206
- const folderEnvs = new Map();
207
- for (const secret of secrets) {
208
- const [secretData] = await client.getSecret({ name: secret.name });
209
- if (secretData.labels?.folder) {
210
- const f = secretData.labels.folder;
211
- const e = secretData.labels?.environment || "(default)";
212
- if (!folderEnvs.has(f)) folderEnvs.set(f, new Set());
213
- folderEnvs.get(f).add(e);
214
- }
509
+ // フォルダ一覧
510
+ const folders = await listDriveFolders(drive, config.driveFolderId);
511
+ if (folders.length === 0) {
512
+ console.log("シークレットが登録されていません");
513
+ break;
215
514
  }
515
+
216
516
  console.log("フォルダ一覧:");
217
- for (const [f, envs] of folderEnvs) {
218
- const envList = Array.from(envs).sort().join(', ');
219
- 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(', ')}]`);
220
525
  }
221
526
  } else {
222
- // 特定フォルダのシークレット一覧 (環境でフィルタ)
223
- console.log(`${folder} (${targetEnv}) のシークレット:`);
224
- for (const secret of secrets) {
225
- const [secretData] = await client.getSecret({ name: secret.name });
226
- const secretEnv = secretData.labels?.environment || null;
227
- if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
228
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
229
- console.log(` ${key}`);
230
- }
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}`);
231
549
  }
232
550
  }
233
551
  break;
234
552
  }
235
553
 
236
554
  case "pull": {
555
+ checkAgeInstalled();
237
556
  const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
238
- const parent = `projects/${config.centralProject}`;
239
- const [secrets] = await client.listSecrets({ parent });
240
-
241
- const envLines = [];
242
- for (const secret of secrets) {
243
- const [secretData] = await client.getSecret({ name: secret.name });
244
- const secretEnv = secretData.labels?.environment || null;
245
- if (secretData.labels?.folder === folder && secretEnv === targetEnv) {
246
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
247
- const [version] = await client.accessSecretVersion({
248
- name: `${secret.name}/versions/latest`,
249
- });
250
- const value = version.payload.data.toString("utf-8");
251
- if (value.includes("\n")) {
252
- envLines.push(`${key}=\`${value}\``);
253
- } else {
254
- envLines.push(`${key}=${value}`);
255
- }
256
- }
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;
257
563
  }
258
- if (envLines.length === 0) {
564
+
565
+ const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
566
+ if (!file) {
259
567
  console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
260
- } else {
261
- console.log(envLines.join("\n"));
568
+ break;
262
569
  }
570
+
571
+ const encrypted = await downloadFile(drive, file.id);
572
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
573
+ console.log(decrypted.trimEnd());
263
574
  break;
264
575
  }
265
576
 
266
577
  case "push": {
578
+ checkAgeInstalled();
267
579
  const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
268
580
  const envFile = parsed.positional[2] || ".env";
269
581
 
@@ -273,67 +585,43 @@ async function runCli(args) {
273
585
  }
274
586
 
275
587
  const content = readFileSync(envFile, "utf-8");
276
- const lines = content.split("\n");
277
- const parent = `projects/${config.centralProject}`;
278
- const labels = { folder, environment: targetEnv };
279
- let count = 0;
280
-
281
- for (const line of lines) {
282
- if (!line.trim() || line.startsWith("#")) continue;
283
- const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/i);
284
- if (match) {
285
- const [, key, value] = match;
286
- const secretId = makeSecretName(folder, key, targetEnv);
287
- const secretName = `${parent}/secrets/${secretId}`;
288
-
289
- try {
290
- await client.getSecret({ name: secretName });
291
- // 既存シークレットのラベルも更新
292
- await client.updateSecret({
293
- secret: { name: secretName, labels },
294
- updateMask: { paths: ['labels'] }
295
- });
296
- await client.addSecretVersion({
297
- parent: secretName,
298
- payload: { data: Buffer.from(value) },
299
- });
300
- } catch {
301
- await client.createSecret({
302
- parent,
303
- secretId,
304
- secret: { replication: { automatic: {} }, labels },
305
- });
306
- await client.addSecretVersion({
307
- parent: secretName,
308
- payload: { data: Buffer.from(value) },
309
- });
310
- }
311
- count++;
312
- }
588
+ const entries = parseEnvFile(content);
589
+ if (entries.length === 0) {
590
+ console.error("有効なシークレットが見つかりません");
591
+ process.exit(1);
313
592
  }
314
- 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})`);
315
602
  break;
316
603
  }
317
604
 
318
605
  case "scan": {
606
+ checkAgeInstalled();
319
607
  const basePath = parsed.positional[1] || homedir();
320
- const filterEnv = parsed.env; // null の場合は全環境を表示
608
+ const filterEnv = parsed.env;
321
609
  const repos = findGitRepositories(basePath, 5);
322
- const parent = `projects/${config.centralProject}`;
323
- const [allSecrets] = await client.listSecrets({ parent });
324
-
325
- // フォルダ+環境ごとにグループ化
326
- const secretsByFolderEnv = new Map();
327
- for (const secret of allSecrets) {
328
- const [secretData] = await client.getSecret({ name: secret.name });
329
- const f = secretData.labels?.folder;
330
- const e = secretData.labels?.environment || null;
331
- if (f) {
332
- const key = `${f}|${e || ''}`;
333
- if (!secretsByFolderEnv.has(key)) secretsByFolderEnv.set(key, []);
334
- secretsByFolderEnv.get(key).push({ secret, env: e });
335
- }
336
- }
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
+ }));
337
625
 
338
626
  const results = [];
339
627
  let syncedCount = 0, diffCount = 0, newCount = 0;
@@ -353,56 +641,52 @@ async function runCli(args) {
353
641
  const localEntries = parseEnvFile(content);
354
642
  if (localEntries.length === 0) continue;
355
643
 
356
- // 環境フィルタがある場合はその環境のみ、なければ全環境をチェック
357
- const envsToCheck = filterEnv ? [filterEnv] : [null, ...Array.from(new Set(
358
- Array.from(secretsByFolderEnv.keys())
359
- .filter(k => k.startsWith(normalizedFolder + '|'))
360
- .map(k => k.split('|')[1])
361
- .filter(Boolean)
362
- ))];
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
+ }
363
658
 
364
659
  for (const checkEnv of envsToCheck) {
365
- const mapKey = `${normalizedFolder}|${checkEnv || ''}`;
366
- const folderSecrets = secretsByFolderEnv.get(mapKey) || [];
367
- const envLabel = checkEnv || "(default)";
660
+ const mapKey = `${normalizedFolder}|${checkEnv}`;
661
+ const remoteEntries = remoteData.get(mapKey);
368
662
 
369
- if (folderSecrets.length === 0) {
370
- 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 });
371
665
  newCount++;
372
666
  continue;
373
667
  }
374
668
 
375
- // リモート値取得・比較
669
+ // 比較
670
+ const remoteMap = new Map(remoteEntries.map(e => [e.key, e.value]));
376
671
  let hasDiff = false;
377
- const remoteKeys = new Set();
378
- const remoteValues = new Map();
379
-
380
- for (const { secret } of folderSecrets) {
381
- const { key } = getKeyFromSecret(secret.name.split('/').pop(), normalizedFolder);
382
- remoteKeys.add(key);
383
- try {
384
- const [version] = await client.accessSecretVersion({ name: `${secret.name}/versions/latest` });
385
- remoteValues.set(key, version.payload.data.toString('utf8'));
386
- } catch { }
387
- }
388
672
 
389
673
  for (const entry of localEntries) {
390
- 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))) {
391
675
  hasDiff = true;
392
676
  break;
393
677
  }
394
678
  }
395
679
  if (!hasDiff) {
396
- for (const key of remoteKeys) {
397
- 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; }
398
682
  }
399
683
  }
400
684
 
401
685
  if (hasDiff) {
402
- 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 });
403
687
  diffCount++;
404
688
  } else {
405
- 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 });
406
690
  syncedCount++;
407
691
  }
408
692
  }
@@ -410,7 +694,7 @@ async function runCli(args) {
410
694
  }
411
695
 
412
696
  const envSuffix = filterEnv ? ` (${filterEnv})` : "";
413
- console.log(`=== Secret Manager 同期状況${envSuffix} ===\n`);
697
+ console.log(`=== シークレット同期状況${envSuffix} ===\n`);
414
698
  if (results.length === 0) {
415
699
  console.log(".env / .dev.vars ファイルが見つかりませんでした");
416
700
  } else {
@@ -432,7 +716,11 @@ async function runCli(args) {
432
716
 
433
717
  case "pre-commit": {
434
718
  // config なし → サイレント exit
435
- if (!config.centralProject) process.exit(0);
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); }
436
724
 
437
725
  const cwd = process.cwd();
438
726
  const folder = normalizeFolder(basename(resolve(cwd)));
@@ -440,9 +728,11 @@ async function runCli(args) {
440
728
  if (envFiles.length === 0) process.exit(0);
441
729
 
442
730
  const cache = readCache();
443
- const parent = `projects/${config.centralProject}`;
444
731
  let totalPushed = 0;
445
732
 
733
+ let drive;
734
+ try { drive = await getDriveClient(config); } catch { process.exit(0); }
735
+
446
736
  for (const envFile of envFiles) {
447
737
  let content;
448
738
  try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
@@ -460,78 +750,41 @@ async function runCli(args) {
460
750
  const localEntries = parseEnvFile(content);
461
751
  if (localEntries.length === 0) continue;
462
752
 
463
- const labels = { folder, environment: targetEnv };
464
-
465
753
  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);
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
+ }
503
779
  }
504
780
  }
505
781
 
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;
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++;
535
788
  } else {
536
789
  console.log(`✓ ${envFile.filename} synced`);
537
790
  }
@@ -559,42 +812,35 @@ async function runCli(args) {
559
812
  process.exit(1);
560
813
  }
561
814
 
815
+ checkAgeInstalled();
562
816
  const filterEnv = parsed.env;
563
- const parent = `projects/${config.centralProject}`;
564
- 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;
565
822
 
566
823
  console.log(`Searching for: "${keyword}"`);
567
824
  if (filterEnv) console.log(` 環境: ${filterEnv}`);
568
- console.log(`\nScanning ${secrets.length} secrets...\n`);
825
+ console.log(`\nScanning ${targetFiles.length} files...\n`);
569
826
 
570
827
  const results = await Promise.all(
571
- secrets.map(async (secret) => {
828
+ targetFiles.map(async (rf) => {
572
829
  try {
573
- const [secretData] = await client.getSecret({ name: secret.name });
574
- const folder = secretData.labels?.folder;
575
- const env = secretData.labels?.environment || "(default)";
576
-
577
- // 環境フィルタ
578
- if (filterEnv && secretData.labels?.environment !== filterEnv) return null;
579
-
580
- // 値を取得してキーワード検索
581
- const [version] = await client.accessSecretVersion({
582
- name: `${secret.name}/versions/latest`,
583
- });
584
- const value = version.payload.data.toString("utf-8");
585
- if (value.includes(keyword)) {
586
- const { key } = getKeyFromSecret(secret.name.split("/").pop(), folder);
587
- return { folder, env, key };
588
- }
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 }));
589
836
  } catch {
590
- // バージョンがない場合はスキップ
837
+ return [];
591
838
  }
592
- return null;
593
839
  })
594
840
  );
595
841
 
596
- const matches = results.filter((r) => r !== null);
597
- const folders = new Set(matches.map((m) => m.folder));
842
+ const matches = results.flat();
843
+ const folders = new Set(matches.map(m => m.folder));
598
844
 
599
845
  if (matches.length === 0) {
600
846
  console.log("No matches found");
@@ -607,6 +853,71 @@ async function runCli(args) {
607
853
  break;
608
854
  }
609
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
+
610
921
  case "hook": {
611
922
  const subcommand = parsed.positional[1];
612
923
 
@@ -627,7 +938,7 @@ async function runCli(args) {
627
938
 
628
939
  if (hookType === 'pre-commit') {
629
940
  extraLogic = `
630
- # gcloud-secrets: auto-sync .env to Secret Manager
941
+ # gcloud-secrets: auto-sync .env to Drive
631
942
  if command -v gcloud-secrets >/dev/null 2>&1; then
632
943
  gcloud-secrets pre-commit
633
944
  fi
@@ -690,18 +1001,21 @@ exit 0
690
1001
  }
691
1002
 
692
1003
  default:
693
- console.log(`gcloud-secrets - GCP Secret Manager CLI
1004
+ console.log(`gcloud-secrets - シークレット管理 CLI (Google Drive + age 暗号化)
694
1005
 
695
1006
  使い方:
696
- gcloud-secrets init <project-id> [--env <default>] 中央プロジェクトを設定
697
- gcloud-secrets list [folder] [--env <env>] 一覧表示
698
- gcloud-secrets pull [folder] [--env <env>] シークレットを取得
699
- gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
700
- gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
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 アンインストール
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 アンインストール
705
1019
 
706
1020
  オプション:
707
1021
  --env, -e <env> 環境を指定 (dev, staging, prod など)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yhonda/gcloud-secrets",
3
- "version": "2.0.12",
4
- "description": "GCP Secret Manager GitHub clone 風に管理する CLI ツール",
3
+ "version": "3.0.0",
4
+ "description": "Google Drive + age 暗号化でシークレットを管理する CLI ツール",
5
5
  "type": "module",
6
6
  "main": "cli.js",
7
7
  "bin": {
@@ -23,8 +23,9 @@
23
23
  "url": "git+https://github.com/yhonda-ohishi/gcloudSec.git"
24
24
  },
25
25
  "keywords": [
26
- "gcp",
27
- "secret-manager",
26
+ "google-drive",
27
+ "age-encryption",
28
+ "secrets",
28
29
  "env",
29
30
  "claude-code",
30
31
  "cli"
@@ -42,7 +43,7 @@
42
43
  },
43
44
  "homepage": "https://github.com/yhonda-ohishi/gcloudSec#readme",
44
45
  "dependencies": {
45
- "@google-cloud/secret-manager": "^6.1.1"
46
+ "googleapis": "^144.0.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "husky": "^9.1.7"
package/skills/secrets.md CHANGED
@@ -1,14 +1,19 @@
1
1
  # Skill: secrets
2
2
 
3
- GCP Secret Manager を使って .env ファイルを管理するスキル
3
+ Google Drive + age 暗号化でシークレットを管理するスキル
4
4
 
5
5
  ## コマンド一覧
6
6
 
7
7
  ### 初期化
8
8
  ```bash
9
- gcloud-secrets init <project-id> [--env <default>]
9
+ gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>]
10
10
  ```
11
- GCP プロジェクト ID を設定します。`--env` でデフォルト環境を指定できます(省略時は `dev`)。
11
+ Google Drive + OAuth + age 鍵の初期設定を行います。
12
+ - `drive-folder-id` 省略時は Drive に "gcloud-secrets" フォルダを自動作成
13
+ - `--client-id` / `--client-secret`: Google Cloud Console で作成した OAuth クライアント情報
14
+ - `--env` でデフォルト環境を指定(省略時は `dev`)
15
+ - `--age-key <path>` で age 秘密鍵パスを指定(省略時は `~/.age/key.txt`、未作成なら自動生成)
16
+ - `--age-pub <key>` で age 公開鍵を指定(省略時は秘密鍵ファイルから自動取得)
12
17
 
13
18
  ### 一覧表示
14
19
  ```bash
@@ -27,7 +32,7 @@ gcloud-secrets pull --env dev
27
32
  # 指定フォルダから取得
28
33
  gcloud-secrets pull <folder> --env prod
29
34
  ```
30
- Secret Manager から .env 形式でシークレットを取得します。
35
+ Drive から暗号化ファイルをダウンロードし、age で復号して .env 形式で出力します。
31
36
 
32
37
  ### シークレット登録 (push)
33
38
  ```bash
@@ -40,6 +45,7 @@ gcloud-secrets push <folder> --env prod
40
45
  # 指定ファイルをアップロード
41
46
  gcloud-secrets push <folder> <file> --env staging
42
47
  ```
48
+ .env ファイルを age で暗号化し、Drive にアップロードします。
43
49
 
44
50
  ### 同期状況スキャン (scan)
45
51
  ```bash
@@ -52,7 +58,7 @@ gcloud-secrets scan --env dev
52
58
  # 指定ディレクトリ以下をスキャン
53
59
  gcloud-secrets scan <path> --env prod
54
60
  ```
55
- Git リポジトリ内の .env / .dev.vars ファイルと Secret Manager の同期状況を確認します。
61
+ Git リポジトリ内の .env / .dev.vars ファイルと Drive 上の暗号化ファイルの同期状況を確認します。
56
62
 
57
63
  ### 値から逆引き検索 (search)
58
64
  ```bash
@@ -62,13 +68,12 @@ gcloud-secrets search "api-key-12345"
62
68
  # 特定環境のみ検索
63
69
  gcloud-secrets search "client-id" --env prod
64
70
  ```
65
- シークレットの値から、使用しているフォルダ・環境・キーを逆引き検索します。
66
71
 
67
72
  出力例:
68
73
  ```
69
74
  Searching for: "api-key-12345"
70
75
 
71
- Scanning 45 secrets...
76
+ Scanning 8 files...
72
77
 
73
78
  [FOUND] my-app / dev - EXTERNAL_API_KEY
74
79
  [FOUND] my-app / prod - EXTERNAL_API_KEY
@@ -79,7 +84,7 @@ Found 3 matches in 2 folders
79
84
 
80
85
  #### scan 出力例:
81
86
  ```
82
- === Secret Manager 同期状況 ===
87
+ === シークレット同期状況 ===
83
88
 
84
89
  [OK] project-a/ .env [dev] (3 keys)
85
90
  [DIFF] project-b/ .env [prod] (2 keys) - 差分あり
@@ -94,11 +99,11 @@ Found 3 matches in 2 folders
94
99
 
95
100
  ### .env 自動同期 (pre-commit)
96
101
  ```bash
97
- # カレントディレクトリの .env を Secret Manager に自動同期
102
+ # カレントディレクトリの .env を Drive に自動同期
98
103
  gcloud-secrets pre-commit
99
104
  ```
100
105
  git hook 用の高速コマンド。キャッシュで .env の変更を検知し、変更がなければ API コール 0 で即座に終了。
101
- 変更があれば `listSecrets` のフィルタ + 並列取得で高速にチェックし、新規/差分のある secret を自動 push。
106
+ 変更があれば Drive からダウンロード+復号で比較し、差分があれば暗号化+アップロード。
102
107
 
103
108
  ### グローバル git hook (hook)
104
109
  ```bash
@@ -124,8 +129,8 @@ gcloud-secrets hook uninstall
124
129
  ## 使用例
125
130
 
126
131
  ```bash
127
- # 1. 初期化 (デフォルト環境を dev に設定)
128
- gcloud-secrets init my-gcp-project --env dev
132
+ # 1. 初期化 (OAuth クライアント情報を設定)
133
+ gcloud-secrets init --client-id "xxx.apps.googleusercontent.com" --client-secret "GOCSPX-xxx" --env dev
129
134
 
130
135
  # 2. dev 環境に .env を登録
131
136
  gcloud-secrets push --env dev