@yhonda/gcloud-secrets 2.0.12 → 3.1.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 +764 -292
  2. package/package.json +6 -5
  3. package/skills/secrets.md +37 -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,408 @@ 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 || "",
49
+ googleDeviceClientId: process.env.GOOGLE_DEVICE_CLIENT_ID || "",
50
+ googleDeviceClientSecret: process.env.GOOGLE_DEVICE_CLIENT_SECRET || "",
35
51
  };
36
52
  if (existsSync(configFile)) {
37
53
  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();
54
+ for (const [envKey, configKey] of [
55
+ ['DRIVE_FOLDER_ID', 'driveFolderId'],
56
+ ['DEFAULT_ENVIRONMENT', 'defaultEnvironment'],
57
+ ['AGE_PUBLIC_KEY', 'agePublicKey'],
58
+ ['AGE_KEY_PATH', 'ageKeyPath'],
59
+ ['GOOGLE_CLIENT_ID', 'googleClientId'],
60
+ ['GOOGLE_CLIENT_SECRET', 'googleClientSecret'],
61
+ ['GOOGLE_DEVICE_CLIENT_ID', 'googleDeviceClientId'],
62
+ ['GOOGLE_DEVICE_CLIENT_SECRET', 'googleDeviceClientSecret'],
63
+ ]) {
64
+ const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm'));
65
+ if (match) config[configKey] = match[1].trim();
45
66
  }
46
67
  }
47
68
  return config;
48
69
  }
49
70
 
50
- // シークレット名生成 (環境対応)
51
- function makeSecretName(folder, key, env = null) {
52
- if (env) {
53
- return `${folder}_${env}_${key}`;
71
+ function writeConfig(values) {
72
+ const configFile = `${homedir()}/.secrets-manager.conf`;
73
+ const lines = [];
74
+ for (const [key, value] of Object.entries(values)) {
75
+ if (value) lines.push(`${key}=${value}`);
54
76
  }
55
- return `${folder}_${key}`;
77
+ writeFileSync(configFile, lines.join('\n') + '\n');
78
+ }
79
+
80
+ // ============================================================
81
+ // age ヘルパー
82
+ // ============================================================
83
+ function checkAgeInstalled() {
84
+ try {
85
+ execFileSync('age', ['--version'], { stdio: 'ignore' });
86
+ } catch {
87
+ console.error('エラー: age がインストールされていません');
88
+ console.error('インストール: sudo apt install age (Linux) / brew install age (macOS)');
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ function ageEncrypt(plaintext, publicKey) {
94
+ return execFileSync('age', ['-r', publicKey], { input: Buffer.from(plaintext) });
56
95
  }
57
96
 
58
- // シークレット名からキーと環境を抽出
59
- function getKeyFromSecret(secretName, folderName) {
60
- const prefix = folderName + "_";
61
- if (!secretName.startsWith(prefix)) {
62
- return { key: secretName, env: null };
97
+ function ageDecrypt(ciphertext, keyPath) {
98
+ return execFileSync('age', ['-d', '-i', keyPath], { input: ciphertext, encoding: 'utf-8' });
99
+ }
100
+
101
+ function getAgePublicKeyFromFile(keyPath) {
102
+ const content = readFileSync(keyPath, 'utf-8');
103
+ const match = content.match(/# public key: (age1[a-z0-9]+)/);
104
+ if (match) return match[1];
105
+ throw new Error('age 公開鍵が見つかりません: ' + keyPath);
106
+ }
107
+
108
+ // ============================================================
109
+ // OAuth2 認証
110
+ // ============================================================
111
+ const OAUTH_REDIRECT_PORT = 3456;
112
+ const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_REDIRECT_PORT}/callback`;
113
+ const OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive'];
114
+ const DEVICE_CODE_URL = 'https://oauth2.googleapis.com/device/code';
115
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
116
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
117
+
118
+ function getTokenPath() {
119
+ return join(homedir(), '.secrets-manager-oauth.json');
120
+ }
121
+
122
+ async function performDeviceFlow(config) {
123
+ if (!config.googleDeviceClientId || !config.googleDeviceClientSecret) {
124
+ throw new Error(
125
+ 'Device flow 用 OAuth client が未設定です。\n' +
126
+ '~/.secrets-manager.conf に GOOGLE_DEVICE_CLIENT_ID / GOOGLE_DEVICE_CLIENT_SECRET を設定してください\n' +
127
+ '(Google Cloud Console → OAuth 2.0 クライアント ID → "TVs and Limited Input devices" タイプ)'
128
+ );
129
+ }
130
+
131
+ // Step 1: Device code 取得
132
+ const deviceRes = await fetch(DEVICE_CODE_URL, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
135
+ body: new URLSearchParams({
136
+ client_id: config.googleDeviceClientId,
137
+ scope: OAUTH_SCOPES.join(' '),
138
+ }),
139
+ });
140
+ if (!deviceRes.ok) {
141
+ const text = await deviceRes.text();
142
+ throw new Error(`Device code リクエスト失敗 (${deviceRes.status}): ${text}`);
143
+ }
144
+ const dc = await deviceRes.json();
145
+ // dc: { device_code, user_code, verification_url, expires_in, interval }
146
+
147
+ // Step 2: ユーザーへ案内
148
+ console.log('\n認証するには、以下の URL を別デバイスのブラウザで開き、コードを入力してください:');
149
+ console.log(` URL: ${dc.verification_url}`);
150
+ console.log(` Code: ${dc.user_code}`);
151
+ console.log(` (有効期限 ${Math.floor(dc.expires_in / 60)} 分、待機中...)\n`);
152
+
153
+ // Step 3: Poll
154
+ const pollInterval = Math.max(dc.interval || 5, 1);
155
+ const deadline = Date.now() + dc.expires_in * 1000;
156
+ let currentInterval = pollInterval;
157
+ while (Date.now() < deadline) {
158
+ await new Promise((r) => setTimeout(r, currentInterval * 1000));
159
+ const tokenRes = await fetch(TOKEN_URL, {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
162
+ body: new URLSearchParams({
163
+ client_id: config.googleDeviceClientId,
164
+ client_secret: config.googleDeviceClientSecret,
165
+ device_code: dc.device_code,
166
+ grant_type: DEVICE_GRANT_TYPE,
167
+ }),
168
+ });
169
+ const data = await tokenRes.json();
170
+ if (data.error === 'authorization_pending') continue;
171
+ if (data.error === 'slow_down') { currentInterval += 5; continue; }
172
+ if (data.error === 'access_denied') throw new Error('ユーザーが認証を拒否しました');
173
+ if (data.error === 'expired_token') throw new Error('Device code が失効しました、再実行してください');
174
+ if (data.error) throw new Error(`Device flow エラー: ${data.error} ${data.error_description || ''}`);
175
+ if (data.access_token) {
176
+ const tokenPath = getTokenPath();
177
+ // _client_type マーカーで、後続の refresh 時にどの client で発行されたか判別する
178
+ const annotated = { ...data, _client_type: 'device' };
179
+ writeFileSync(tokenPath, JSON.stringify(annotated, null, 2));
180
+ console.log('認証完了');
181
+ return annotated;
182
+ }
183
+ }
184
+ throw new Error('Device flow タイムアウト');
185
+ }
186
+
187
+ function isInvalidGrantError(err) {
188
+ const msg = String(err?.message || '');
189
+ const data = err?.response?.data || {};
190
+ return msg.includes('invalid_grant') || data.error === 'invalid_grant';
191
+ }
192
+
193
+ async function performOAuthFlow(oauth2Client) {
194
+ const authUrl = oauth2Client.generateAuthUrl({
195
+ access_type: 'offline',
196
+ scope: OAUTH_SCOPES,
197
+ prompt: 'consent',
198
+ });
199
+
200
+ console.log('ブラウザで認証を行います...');
201
+ let browserOpened = false;
202
+ try {
203
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
204
+ execSync(`${openCmd} "${authUrl}"`, { stdio: 'ignore' });
205
+ browserOpened = true;
206
+ } catch { }
207
+ if (!browserOpened) {
208
+ console.log(`ブラウザが開けませんでした。以下のURLを手動で開いてください:\n${authUrl}`);
209
+ } else {
210
+ // リモートセッションなど stdio 共有環境用に URL も常時表示
211
+ console.log(`(ブラウザが開かない場合は以下を開いてください)\n${authUrl}`);
212
+ }
213
+
214
+ const code = await new Promise((resolve, reject) => {
215
+ const server = createServer((req, res) => {
216
+ const url = new URL(req.url, `http://localhost:${OAUTH_REDIRECT_PORT}`);
217
+ const authCode = url.searchParams.get('code');
218
+ const error = url.searchParams.get('error');
219
+ if (error) {
220
+ res.end('認証がキャンセルされました。');
221
+ server.close();
222
+ reject(new Error(`OAuth error: ${error}`));
223
+ return;
224
+ }
225
+ if (authCode) {
226
+ res.end('認証完了!このタブを閉じてください。');
227
+ server.close();
228
+ resolve(authCode);
229
+ }
230
+ });
231
+ server.listen(OAUTH_REDIRECT_PORT, () => {
232
+ console.log(`認証待機中... (localhost:${OAUTH_REDIRECT_PORT})`);
233
+ });
234
+ });
235
+
236
+ const { tokens } = await oauth2Client.getToken(code);
237
+ oauth2Client.setCredentials(tokens);
238
+ const tokenPath = getTokenPath();
239
+ writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
240
+ console.log('認証完了');
241
+
242
+ oauth2Client.on('tokens', (newTokens) => {
243
+ try {
244
+ const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
245
+ writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
246
+ } catch { }
247
+ });
248
+
249
+ return oauth2Client;
250
+ }
251
+
252
+ async function getAuthClient(config) {
253
+ const tokenPath = getTokenPath();
254
+ // 既存 token があれば、発行元 client (desktop / device) を判別して対応する credential を使う
255
+ if (existsSync(tokenPath)) {
256
+ const tokens = JSON.parse(readFileSync(tokenPath, 'utf-8'));
257
+ const isDeviceToken = tokens._client_type === 'device';
258
+ const clientId = isDeviceToken ? config.googleDeviceClientId : config.googleClientId;
259
+ const clientSecret = isDeviceToken ? config.googleDeviceClientSecret : config.googleClientSecret;
260
+ if (!clientId || !clientSecret) {
261
+ console.error(`エラー: token (${isDeviceToken ? 'device' : 'desktop'}) 発行元の OAuth client 情報が未設定です`);
262
+ process.exit(1);
263
+ }
264
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, OAUTH_REDIRECT_URI);
265
+ oauth2Client.setCredentials(tokens);
266
+ oauth2Client.on('tokens', (newTokens) => {
267
+ try {
268
+ const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
269
+ writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
270
+ } catch { }
271
+ });
272
+ return oauth2Client;
273
+ }
274
+
275
+ // token 無し → 初回 desktop OAuth (init 用)
276
+ if (!config.googleClientId || !config.googleClientSecret) {
277
+ console.error('エラー: Google OAuth クライアント ID/Secret が設定されていません');
278
+ console.error('init コマンドで --client-id と --client-secret を指定してください');
279
+ process.exit(1);
63
280
  }
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] };
281
+ const oauth2Client = new google.auth.OAuth2(
282
+ config.googleClientId,
283
+ config.googleClientSecret,
284
+ OAUTH_REDIRECT_URI
285
+ );
286
+ return performOAuthFlow(oauth2Client);
287
+ }
288
+
289
+ async function getDriveClient(config) {
290
+ const auth = await getAuthClient(config);
291
+ return google.drive({ version: 'v3', auth });
292
+ }
293
+
294
+ // ============================================================
295
+ // Drive ヘルパー
296
+ // ============================================================
297
+ function escapeQuery(str) {
298
+ return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
299
+ }
300
+
301
+ async function listDriveFolders(drive, rootFolderId) {
302
+ const res = await drive.files.list({
303
+ q: `'${escapeQuery(rootFolderId)}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
304
+ fields: 'files(id, name)',
305
+ pageSize: 1000,
306
+ });
307
+ return res.data.files || [];
308
+ }
309
+
310
+ async function findFolder(drive, parentId, folderName) {
311
+ const res = await drive.files.list({
312
+ q: `'${escapeQuery(parentId)}' in parents and name = '${escapeQuery(folderName)}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
313
+ fields: 'files(id, name)',
314
+ pageSize: 1,
315
+ });
316
+ return (res.data.files || [])[0] || null;
317
+ }
318
+
319
+ async function getOrCreateFolder(drive, parentId, folderName) {
320
+ const existing = await findFolder(drive, parentId, folderName);
321
+ if (existing) return existing;
322
+
323
+ const res = await drive.files.create({
324
+ requestBody: {
325
+ name: folderName,
326
+ mimeType: 'application/vnd.google-apps.folder',
327
+ parents: [parentId],
328
+ },
329
+ fields: 'id, name',
330
+ });
331
+ return res.data;
332
+ }
333
+
334
+ async function findEnvAgeFile(drive, parentFolderId, env) {
335
+ const fileName = `${env}.env.age`;
336
+ const res = await drive.files.list({
337
+ q: `'${escapeQuery(parentFolderId)}' in parents and name = '${escapeQuery(fileName)}' and trashed = false`,
338
+ fields: 'files(id, name)',
339
+ pageSize: 1,
340
+ });
341
+ return (res.data.files || [])[0] || null;
342
+ }
343
+
344
+ async function listEnvAgeFiles(drive, parentFolderId) {
345
+ const res = await drive.files.list({
346
+ q: `'${escapeQuery(parentFolderId)}' in parents and name contains '.env.age' and trashed = false`,
347
+ fields: 'files(id, name)',
348
+ pageSize: 1000,
349
+ });
350
+ return res.data.files || [];
351
+ }
352
+
353
+ async function downloadFile(drive, fileId) {
354
+ const res = await drive.files.get({
355
+ fileId,
356
+ alt: 'media',
357
+ }, { responseType: 'arraybuffer' });
358
+ return Buffer.from(res.data);
359
+ }
360
+
361
+ async function uploadFile(drive, parentFolderId, fileName, content) {
362
+ const res = await drive.files.list({
363
+ q: `'${escapeQuery(parentFolderId)}' in parents and name = '${escapeQuery(fileName)}' and trashed = false`,
364
+ fields: 'files(id)',
365
+ pageSize: 1,
366
+ });
367
+
368
+ const existing = (res.data.files || [])[0];
369
+
370
+ if (existing) {
371
+ await drive.files.update({
372
+ fileId: existing.id,
373
+ media: {
374
+ mimeType: 'application/octet-stream',
375
+ body: Readable.from(content),
376
+ },
377
+ });
378
+ return existing.id;
379
+ } else {
380
+ const created = await drive.files.create({
381
+ requestBody: {
382
+ name: fileName,
383
+ parents: [parentFolderId],
384
+ },
385
+ media: {
386
+ mimeType: 'application/octet-stream',
387
+ body: Readable.from(content),
388
+ },
389
+ fields: 'id',
390
+ });
391
+ return created.data.id;
69
392
  }
70
- return { key: rest, env: null };
71
393
  }
72
394
 
73
- // フォルダ名を正規化 (camelCase kebab-case)
395
+ async function listAllEnvFiles(drive, rootFolderId) {
396
+ const folders = await listDriveFolders(drive, rootFolderId);
397
+ const result = [];
398
+
399
+ await Promise.all(folders.map(async (folder) => {
400
+ const files = await listEnvAgeFiles(drive, folder.id);
401
+ for (const file of files) {
402
+ const envMatch = file.name.match(/^(.+)\.env\.age$/);
403
+ if (envMatch) {
404
+ result.push({
405
+ fileId: file.id,
406
+ fileName: file.name,
407
+ folder: folder.name,
408
+ folderId: folder.id,
409
+ env: envMatch[1],
410
+ });
411
+ }
412
+ }
413
+ }));
414
+
415
+ return result;
416
+ }
417
+
418
+ // ============================================================
419
+ // ユーティリティ (変更なし)
420
+ // ============================================================
74
421
  function normalizeFolder(name) {
75
422
  return name
76
- .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase → kebab-case
423
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
77
424
  .toLowerCase()
78
425
  .replace(/[^a-z0-9_-]/g, '-');
79
426
  }
80
427
 
81
- // Git リポジトリを再帰的に検索
82
428
  function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
83
429
  const repos = [];
84
430
  if (currentDepth > maxDepth) return repos;
85
-
86
431
  try {
87
432
  const entries = readdirSync(basePath, { withFileTypes: true });
88
433
  for (const entry of entries) {
89
434
  if (!entry.isDirectory()) continue;
90
435
  if (entry.name.startsWith('.') && entry.name !== '.git') continue;
91
436
  if (entry.name === 'node_modules') continue;
92
-
93
437
  const fullPath = join(basePath, entry.name);
94
- try {
95
- if (lstatSync(fullPath).isSymbolicLink()) continue;
96
- } catch { continue; }
97
-
438
+ try { if (lstatSync(fullPath).isSymbolicLink()) continue; } catch { continue; }
98
439
  if (entry.name === '.git') {
99
440
  repos.push(dirname(fullPath));
100
441
  } else {
@@ -105,7 +446,6 @@ function findGitRepositories(basePath, maxDepth = 5, currentDepth = 0) {
105
446
  return repos;
106
447
  }
107
448
 
108
- // .env ファイルを検索
109
449
  function findEnvFiles(repoPath) {
110
450
  const envFiles = [];
111
451
  for (const filename of ['.env', '.dev.vars', '.env.local', '.env.production']) {
@@ -122,7 +462,6 @@ function findEnvFiles(repoPath) {
122
462
  return envFiles;
123
463
  }
124
464
 
125
- // .env ファイルをパース
126
465
  function parseEnvFile(content) {
127
466
  const entries = [];
128
467
  const multilineRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*`([\s\S]*?)`/gm;
@@ -142,7 +481,6 @@ function parseEnvFile(content) {
142
481
  return entries;
143
482
  }
144
483
 
145
- // 値の比較
146
484
  function compareValues(a, b) {
147
485
  return a.trim().replace(/\r\n/g, '\n') === b.trim().replace(/\r\n/g, '\n');
148
486
  }
@@ -168,14 +506,16 @@ function hashContent(content) {
168
506
  return createHash('md5').update(content).digest('hex');
169
507
  }
170
508
 
171
- // CLI モード
509
+ // ============================================================
510
+ // CLI メイン
511
+ // ============================================================
172
512
  async function runCli(args) {
173
513
  const parsed = parseArgs(args);
174
514
  const command = parsed.positional[0];
175
515
  const config = getConfig();
176
516
  const targetEnv = parsed.env || config.defaultEnvironment;
177
517
 
178
- if (!config.centralProject && command !== "init" && command !== "pre-commit" && command !== "hook") {
518
+ if (!config.driveFolderId && command && command !== "init" && command !== "pre-commit" && command !== "hook") {
179
519
  console.error("エラー: 先に init を実行してください");
180
520
  process.exit(1);
181
521
  }
@@ -183,87 +523,204 @@ async function runCli(args) {
183
523
  try {
184
524
  switch (command) {
185
525
  case "init": {
186
- const projectId = parsed.positional[1];
526
+ const driveFolderId = parsed.positional[1];
187
527
  const defaultEnv = parsed.env || "dev";
188
- if (!projectId) {
189
- console.error("使い方: gcloud-secrets init <project-id> [--env <default-env>]");
528
+ const clientId = parsed.clientId || config.googleClientId;
529
+ const clientSecret = parsed.clientSecret || config.googleClientSecret;
530
+
531
+ if (!clientId || !clientSecret) {
532
+ console.error("使い方: gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>] [--age-pub <key>] [--age-key <path>]");
190
533
  process.exit(1);
191
534
  }
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})`);
535
+
536
+ // age チェック
537
+ checkAgeInstalled();
538
+
539
+ // age 鍵の設定
540
+ let ageKeyPath = parsed.ageKey || config.ageKeyPath;
541
+ let agePublicKey = parsed.agePub || config.agePublicKey;
542
+
543
+ if (!existsSync(ageKeyPath)) {
544
+ const ageDir = dirname(ageKeyPath);
545
+ if (!existsSync(ageDir)) mkdirSync(ageDir, { recursive: true });
546
+ console.log(`age 鍵を生成中: ${ageKeyPath}`);
547
+ execFileSync('age-keygen', ['-o', ageKeyPath]);
548
+ }
549
+
550
+ if (!agePublicKey) {
551
+ agePublicKey = getAgePublicKeyFromFile(ageKeyPath);
552
+ }
553
+
554
+ // OAuth 認証テスト
555
+ const tempConfig = { ...config, googleClientId: clientId, googleClientSecret: clientSecret };
556
+ const drive = await getDriveClient(tempConfig);
557
+
558
+ let folderId = driveFolderId;
559
+ if (!folderId) {
560
+ // ルートフォルダ作成
561
+ console.log('Drive にルートフォルダ "gcloud-secrets" を作成中...');
562
+ const res = await drive.files.create({
563
+ requestBody: {
564
+ name: 'gcloud-secrets',
565
+ mimeType: 'application/vnd.google-apps.folder',
566
+ },
567
+ fields: 'id, name',
568
+ });
569
+ folderId = res.data.id;
570
+ console.log(`フォルダ作成完了: ${res.data.name} (${folderId})`);
571
+ } else {
572
+ // 既存フォルダの検証
573
+ try {
574
+ const res = await drive.files.get({ fileId: folderId, fields: 'id, name' });
575
+ console.log(`Drive フォルダ確認: ${res.data.name} (${folderId})`);
576
+ } catch {
577
+ console.error(`エラー: Drive フォルダ ID "${folderId}" にアクセスできません`);
578
+ process.exit(1);
579
+ }
580
+ }
581
+
582
+ // 設定保存
583
+ writeConfig({
584
+ DRIVE_FOLDER_ID: folderId,
585
+ DEFAULT_ENVIRONMENT: defaultEnv,
586
+ AGE_PUBLIC_KEY: agePublicKey,
587
+ AGE_KEY_PATH: ageKeyPath,
588
+ GOOGLE_CLIENT_ID: clientId,
589
+ GOOGLE_CLIENT_SECRET: clientSecret,
590
+ });
591
+
592
+ console.log(`設定完了:
593
+ Drive フォルダ: ${folderId}
594
+ デフォルト環境: ${defaultEnv}
595
+ age 公開鍵: ${agePublicKey}
596
+ age 秘密鍵: ${ageKeyPath}`);
597
+ break;
598
+ }
599
+
600
+ case "reauth": {
601
+ // init 済み前提
602
+ if (!config.driveFolderId) {
603
+ console.error('エラー: DRIVE_FOLDER_ID が未設定です');
604
+ console.error('先に init を実行してください');
605
+ process.exit(1);
606
+ }
607
+ if (!config.googleDeviceClientId || !config.googleDeviceClientSecret) {
608
+ console.error('エラー: Device flow 用 OAuth client が未設定です');
609
+ console.error('~/.secrets-manager.conf に GOOGLE_DEVICE_CLIENT_ID / GOOGLE_DEVICE_CLIENT_SECRET を追加してください');
610
+ console.error('(Google Cloud Console → OAuth 2.0 クライアント ID → "TVs and Limited Input devices" タイプを作成)');
611
+ process.exit(1);
612
+ }
613
+
614
+ // 失効 token を退避
615
+ const tokenPath = getTokenPath();
616
+ if (existsSync(tokenPath)) {
617
+ const backupPath = `${tokenPath}.stale-${Date.now()}`;
618
+ try {
619
+ writeFileSync(backupPath, readFileSync(tokenPath));
620
+ rmSync(tokenPath);
621
+ console.log(`旧 token を退避: ${backupPath}`);
622
+ } catch (e) {
623
+ console.error(`警告: 旧 token の退避に失敗: ${e.message}`);
624
+ }
625
+ }
626
+
627
+ // Device Flow 実行 (別デバイスのブラウザで承認)
628
+ const tokens = await performDeviceFlow(config);
629
+
630
+ // googleapis の OAuth2 クライアントにトークンを流し込んで Drive 疎通確認
631
+ const oauth2Client = new google.auth.OAuth2(
632
+ config.googleDeviceClientId,
633
+ config.googleDeviceClientSecret
634
+ );
635
+ oauth2Client.setCredentials(tokens);
636
+ const drive = google.drive({ version: 'v3', auth: oauth2Client });
637
+ try {
638
+ const res = await drive.files.get({ fileId: config.driveFolderId, fields: 'id, name' });
639
+ console.log(`Drive フォルダ確認: ${res.data.name} (${config.driveFolderId})`);
640
+ } catch (e) {
641
+ console.error(`警告: Drive フォルダ検証失敗 (${e.message})`);
642
+ console.error('token は更新済み。フォルダ ID / 権限を確認してください');
643
+ process.exit(1);
644
+ }
645
+ console.log('再認証完了');
196
646
  break;
197
647
  }
198
648
 
199
649
  case "list": {
200
650
  const folder = parsed.positional[1];
201
- const parent = `projects/${config.centralProject}`;
202
- const [secrets] = await client.listSecrets({ parent });
651
+ const drive = await getDriveClient(config);
203
652
 
204
653
  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
- }
654
+ // フォルダ一覧
655
+ const folders = await listDriveFolders(drive, config.driveFolderId);
656
+ if (folders.length === 0) {
657
+ console.log("シークレットが登録されていません");
658
+ break;
215
659
  }
660
+
216
661
  console.log("フォルダ一覧:");
217
- for (const [f, envs] of folderEnvs) {
218
- const envList = Array.from(envs).sort().join(', ');
219
- console.log(` ${f} [${envList}]`);
662
+ for (const f of folders) {
663
+ const files = await listEnvAgeFiles(drive, f.id);
664
+ const envs = files
665
+ .map(file => file.name.match(/^(.+)\.env\.age$/))
666
+ .filter(Boolean)
667
+ .map(m => m[1])
668
+ .sort();
669
+ console.log(` ${f.name} [${envs.join(', ')}]`);
220
670
  }
221
671
  } 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
- }
672
+ // 特定フォルダのキー一覧
673
+ checkAgeInstalled();
674
+ const normalizedFolder = normalizeFolder(folder);
675
+ const folderObj = await findFolder(drive, config.driveFolderId, normalizedFolder);
676
+ if (!folderObj) {
677
+ console.error(`フォルダが見つかりません: ${normalizedFolder}`);
678
+ process.exit(1);
679
+ }
680
+
681
+ const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
682
+ if (!file) {
683
+ console.error(`${normalizedFolder} (${targetEnv}) にシークレットが見つかりません`);
684
+ break;
685
+ }
686
+
687
+ const encrypted = await downloadFile(drive, file.id);
688
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
689
+ const entries = parseEnvFile(decrypted);
690
+
691
+ console.log(`${normalizedFolder} (${targetEnv}) のシークレット:`);
692
+ for (const entry of entries) {
693
+ console.log(` ${entry.key}`);
231
694
  }
232
695
  }
233
696
  break;
234
697
  }
235
698
 
236
699
  case "pull": {
700
+ checkAgeInstalled();
237
701
  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
- }
702
+ const drive = await getDriveClient(config);
703
+
704
+ const folderObj = await findFolder(drive, config.driveFolderId, folder);
705
+ if (!folderObj) {
706
+ console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
707
+ break;
257
708
  }
258
- if (envLines.length === 0) {
709
+
710
+ const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
711
+ if (!file) {
259
712
  console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
260
- } else {
261
- console.log(envLines.join("\n"));
713
+ break;
262
714
  }
715
+
716
+ const encrypted = await downloadFile(drive, file.id);
717
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
718
+ console.log(decrypted.trimEnd());
263
719
  break;
264
720
  }
265
721
 
266
722
  case "push": {
723
+ checkAgeInstalled();
267
724
  const folder = normalizeFolder(parsed.positional[1] || basename(process.cwd()));
268
725
  const envFile = parsed.positional[2] || ".env";
269
726
 
@@ -273,67 +730,43 @@ async function runCli(args) {
273
730
  }
274
731
 
275
732
  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
- }
733
+ const entries = parseEnvFile(content);
734
+ if (entries.length === 0) {
735
+ console.error("有効なシークレットが見つかりません");
736
+ process.exit(1);
313
737
  }
314
- console.log(`${count} 件のシークレットをアップロードしました (${folder}/${targetEnv})`);
738
+
739
+ const drive = await getDriveClient(config);
740
+ const folderObj = await getOrCreateFolder(drive, config.driveFolderId, folder);
741
+
742
+ const encrypted = ageEncrypt(content, config.agePublicKey);
743
+ const fileName = `${targetEnv}.env.age`;
744
+ await uploadFile(drive, folderObj.id, fileName, encrypted);
745
+
746
+ console.log(`${entries.length} 件のシークレットをアップロードしました (${folder}/${targetEnv})`);
315
747
  break;
316
748
  }
317
749
 
318
750
  case "scan": {
751
+ checkAgeInstalled();
319
752
  const basePath = parsed.positional[1] || homedir();
320
- const filterEnv = parsed.env; // null の場合は全環境を表示
753
+ const filterEnv = parsed.env;
321
754
  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
- }
755
+ const drive = await getDriveClient(config);
756
+
757
+ // リモートの全ファイルを取得
758
+ const remoteFiles = await listAllEnvFiles(drive, config.driveFolderId);
759
+
760
+ // リモートデータをダウンロード・復号(並列)
761
+ const remoteData = new Map(); // key: "folder|env" -> parsed entries
762
+ await Promise.all(remoteFiles.map(async (rf) => {
763
+ try {
764
+ const encrypted = await downloadFile(drive, rf.fileId);
765
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
766
+ const entries = parseEnvFile(decrypted);
767
+ remoteData.set(`${rf.folder}|${rf.env}`, entries);
768
+ } catch { }
769
+ }));
337
770
 
338
771
  const results = [];
339
772
  let syncedCount = 0, diffCount = 0, newCount = 0;
@@ -353,56 +786,52 @@ async function runCli(args) {
353
786
  const localEntries = parseEnvFile(content);
354
787
  if (localEntries.length === 0) continue;
355
788
 
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
- ))];
789
+ // チェック対象の環境を決定
790
+ const envsToCheck = filterEnv
791
+ ? [filterEnv]
792
+ : [...new Set(
793
+ Array.from(remoteData.keys())
794
+ .filter(k => k.startsWith(normalizedFolder + '|'))
795
+ .map(k => k.split('|')[1])
796
+ )];
797
+
798
+ if (envsToCheck.length === 0) {
799
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: filterEnv || "(default)", keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
800
+ newCount++;
801
+ continue;
802
+ }
363
803
 
364
804
  for (const checkEnv of envsToCheck) {
365
- const mapKey = `${normalizedFolder}|${checkEnv || ''}`;
366
- const folderSecrets = secretsByFolderEnv.get(mapKey) || [];
367
- const envLabel = checkEnv || "(default)";
805
+ const mapKey = `${normalizedFolder}|${checkEnv}`;
806
+ const remoteEntries = remoteData.get(mapKey);
368
807
 
369
- if (folderSecrets.length === 0) {
370
- results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
808
+ if (!remoteEntries) {
809
+ results.push({ status: "NEW", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
371
810
  newCount++;
372
811
  continue;
373
812
  }
374
813
 
375
- // リモート値取得・比較
814
+ // 比較
815
+ const remoteMap = new Map(remoteEntries.map(e => [e.key, e.value]));
376
816
  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
817
 
389
818
  for (const entry of localEntries) {
390
- if (!remoteKeys.has(entry.key) || !compareValues(entry.value, remoteValues.get(entry.key) || '')) {
819
+ if (!remoteMap.has(entry.key) || !compareValues(entry.value, remoteMap.get(entry.key))) {
391
820
  hasDiff = true;
392
821
  break;
393
822
  }
394
823
  }
395
824
  if (!hasDiff) {
396
- for (const key of remoteKeys) {
397
- if (!localEntries.find(e => e.key === key)) { hasDiff = true; break; }
825
+ for (const re of remoteEntries) {
826
+ if (!localEntries.find(e => e.key === re.key)) { hasDiff = true; break; }
398
827
  }
399
828
  }
400
829
 
401
830
  if (hasDiff) {
402
- results.push({ status: "DIFF", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
831
+ results.push({ status: "DIFF", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
403
832
  diffCount++;
404
833
  } else {
405
- results.push({ status: "OK", repo: repoName, file: envFile.filename, env: envLabel, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
834
+ results.push({ status: "OK", repo: repoName, file: envFile.filename, env: checkEnv, keyCount: localEntries.length, gitIgnored: envFile.gitIgnored });
406
835
  syncedCount++;
407
836
  }
408
837
  }
@@ -410,7 +839,7 @@ async function runCli(args) {
410
839
  }
411
840
 
412
841
  const envSuffix = filterEnv ? ` (${filterEnv})` : "";
413
- console.log(`=== Secret Manager 同期状況${envSuffix} ===\n`);
842
+ console.log(`=== シークレット同期状況${envSuffix} ===\n`);
414
843
  if (results.length === 0) {
415
844
  console.log(".env / .dev.vars ファイルが見つかりませんでした");
416
845
  } else {
@@ -432,7 +861,11 @@ async function runCli(args) {
432
861
 
433
862
  case "pre-commit": {
434
863
  // config なし → サイレント exit
435
- if (!config.centralProject) process.exit(0);
864
+ if (!config.driveFolderId || !config.googleClientId) process.exit(0);
865
+ // OAuth トークンなし → サイレント exit (対話的認証を避ける)
866
+ if (!existsSync(getTokenPath())) process.exit(0);
867
+ // age なし → サイレント exit
868
+ try { execFileSync('age', ['--version'], { stdio: 'ignore' }); } catch { process.exit(0); }
436
869
 
437
870
  const cwd = process.cwd();
438
871
  const folder = normalizeFolder(basename(resolve(cwd)));
@@ -440,10 +873,20 @@ async function runCli(args) {
440
873
  if (envFiles.length === 0) process.exit(0);
441
874
 
442
875
  const cache = readCache();
443
- const parent = `projects/${config.centralProject}`;
444
876
  let totalPushed = 0;
445
877
 
878
+ let drive;
879
+ try { drive = await getDriveClient(config); } catch { process.exit(0); }
880
+
881
+ let reauthWarned = false;
882
+ const warnReauth = () => {
883
+ if (reauthWarned) return;
884
+ reauthWarned = true;
885
+ console.error('⚠ OAuth expired. Run `gcloud-secrets reauth` to re-authenticate.');
886
+ };
887
+
446
888
  for (const envFile of envFiles) {
889
+ if (reauthWarned) break;
447
890
  let content;
448
891
  try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
449
892
  if (!content.trim()) continue;
@@ -460,78 +903,41 @@ async function runCli(args) {
460
903
  const localEntries = parseEnvFile(content);
461
904
  if (localEntries.length === 0) continue;
462
905
 
463
- const labels = { folder, environment: targetEnv };
464
-
465
906
  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);
907
+ // リモートファイルを取得して比較
908
+ const folderObj = await findFolder(drive, config.driveFolderId, folder);
909
+ let needsPush = true;
910
+
911
+ if (folderObj) {
912
+ const remoteFile = await findEnvAgeFile(drive, folderObj.id, targetEnv);
913
+ if (remoteFile) {
914
+ const encrypted = await downloadFile(drive, remoteFile.id);
915
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
916
+ const remoteEntries = parseEnvFile(decrypted);
917
+ const remoteMap = new Map(remoteEntries.map(e => [e.key, e.value]));
918
+
919
+ // 差分チェック
920
+ needsPush = false;
921
+ for (const entry of localEntries) {
922
+ if (!remoteMap.has(entry.key) || !compareValues(entry.value, remoteMap.get(entry.key))) {
923
+ needsPush = true;
924
+ break;
925
+ }
926
+ }
927
+ if (!needsPush) {
928
+ for (const re of remoteEntries) {
929
+ if (!localEntries.find(e => e.key === re.key)) { needsPush = true; break; }
930
+ }
931
+ }
503
932
  }
504
933
  }
505
934
 
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;
935
+ if (needsPush) {
936
+ const targetFolder = await getOrCreateFolder(drive, config.driveFolderId, folder);
937
+ const encryptedContent = ageEncrypt(content, config.agePublicKey);
938
+ await uploadFile(drive, targetFolder.id, `${targetEnv}.env.age`, encryptedContent);
939
+ console.log(`↑ ${envFile.filename}: pushed (${folder}/${targetEnv})`);
940
+ totalPushed++;
535
941
  } else {
536
942
  console.log(`✓ ${envFile.filename} synced`);
537
943
  }
@@ -544,6 +950,10 @@ async function runCli(args) {
544
950
  syncedAt: new Date().toISOString()
545
951
  };
546
952
  } catch (error) {
953
+ if (isInvalidGrantError(error)) {
954
+ warnReauth();
955
+ break;
956
+ }
547
957
  console.log(`⚠ ${envFile.filename}: sync skipped (${error.message})`);
548
958
  }
549
959
  }
@@ -559,42 +969,35 @@ async function runCli(args) {
559
969
  process.exit(1);
560
970
  }
561
971
 
972
+ checkAgeInstalled();
562
973
  const filterEnv = parsed.env;
563
- const parent = `projects/${config.centralProject}`;
564
- const [secrets] = await client.listSecrets({ parent });
974
+ const drive = await getDriveClient(config);
975
+ const allFiles = await listAllEnvFiles(drive, config.driveFolderId);
976
+
977
+ // 環境フィルタ
978
+ const targetFiles = filterEnv ? allFiles.filter(f => f.env === filterEnv) : allFiles;
565
979
 
566
980
  console.log(`Searching for: "${keyword}"`);
567
981
  if (filterEnv) console.log(` 環境: ${filterEnv}`);
568
- console.log(`\nScanning ${secrets.length} secrets...\n`);
982
+ console.log(`\nScanning ${targetFiles.length} files...\n`);
569
983
 
570
984
  const results = await Promise.all(
571
- secrets.map(async (secret) => {
985
+ targetFiles.map(async (rf) => {
572
986
  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
- }
987
+ const encrypted = await downloadFile(drive, rf.fileId);
988
+ const decrypted = ageDecrypt(encrypted, config.ageKeyPath);
989
+ const entries = parseEnvFile(decrypted);
990
+ return entries
991
+ .filter(e => e.value.includes(keyword))
992
+ .map(e => ({ folder: rf.folder, env: rf.env, key: e.key }));
589
993
  } catch {
590
- // バージョンがない場合はスキップ
994
+ return [];
591
995
  }
592
- return null;
593
996
  })
594
997
  );
595
998
 
596
- const matches = results.filter((r) => r !== null);
597
- const folders = new Set(matches.map((m) => m.folder));
999
+ const matches = results.flat();
1000
+ const folders = new Set(matches.map(m => m.folder));
598
1001
 
599
1002
  if (matches.length === 0) {
600
1003
  console.log("No matches found");
@@ -607,6 +1010,71 @@ async function runCli(args) {
607
1010
  break;
608
1011
  }
609
1012
 
1013
+ case "key": {
1014
+ const subcommand = parsed.positional[1];
1015
+
1016
+ if (subcommand === "backup") {
1017
+ checkAgeInstalled();
1018
+ if (!existsSync(config.ageKeyPath)) {
1019
+ console.error(`エラー: age 秘密鍵が見つかりません: ${config.ageKeyPath}`);
1020
+ process.exit(1);
1021
+ }
1022
+
1023
+ // gpg で暗号化
1024
+ try { execFileSync('gpg', ['--version'], { stdio: 'ignore' }); } catch {
1025
+ console.error('エラー: gpg がインストールされていません');
1026
+ process.exit(1);
1027
+ }
1028
+
1029
+ const tmpGpg = join(homedir(), '.age', 'age-key.gpg');
1030
+ console.log('gpg パスワードを入力してください(復元時に必要)...');
1031
+ execSync(`gpg --symmetric --cipher-algo AES256 -o "${tmpGpg}" "${config.ageKeyPath}"`, { stdio: 'inherit' });
1032
+
1033
+ // Drive にアップロード
1034
+ const drive = await getDriveClient(config);
1035
+ const gpgContent = readFileSync(tmpGpg);
1036
+ await uploadFile(drive, config.driveFolderId, 'age-key.gpg', gpgContent);
1037
+ rmSync(tmpGpg);
1038
+
1039
+ console.log('age 秘密鍵を暗号化して Drive にバックアップしました (age-key.gpg)');
1040
+
1041
+ } else if (subcommand === "restore") {
1042
+ // Drive からダウンロード
1043
+ const drive = await getDriveClient(config);
1044
+ const res = await drive.files.list({
1045
+ q: `'${escapeQuery(config.driveFolderId)}' in parents and name = 'age-key.gpg' and trashed = false`,
1046
+ fields: 'files(id)',
1047
+ pageSize: 1,
1048
+ });
1049
+ const file = (res.data.files || [])[0];
1050
+ if (!file) {
1051
+ console.error('エラー: Drive に age-key.gpg が見つかりません');
1052
+ process.exit(1);
1053
+ }
1054
+
1055
+ const gpgData = await downloadFile(drive, file.id);
1056
+ const tmpGpg = join(homedir(), '.age', 'age-key.gpg');
1057
+ const ageDir = dirname(config.ageKeyPath);
1058
+ if (!existsSync(ageDir)) mkdirSync(ageDir, { recursive: true });
1059
+
1060
+ writeFileSync(tmpGpg, gpgData);
1061
+ console.log('gpg パスワードを入力してください...');
1062
+ execSync(`gpg --decrypt -o "${config.ageKeyPath}" "${tmpGpg}"`, { stdio: 'inherit' });
1063
+ rmSync(tmpGpg);
1064
+
1065
+ // 公開鍵も表示
1066
+ const pubKey = getAgePublicKeyFromFile(config.ageKeyPath);
1067
+ console.log(`age 秘密鍵を復元しました: ${config.ageKeyPath}`);
1068
+ console.log(`公開鍵: ${pubKey}`);
1069
+
1070
+ } else {
1071
+ console.log(`使い方:
1072
+ gcloud-secrets key backup age 秘密鍵を gpg 暗号化して Drive にバックアップ
1073
+ gcloud-secrets key restore Drive から age 秘密鍵を復元`);
1074
+ }
1075
+ break;
1076
+ }
1077
+
610
1078
  case "hook": {
611
1079
  const subcommand = parsed.positional[1];
612
1080
 
@@ -627,7 +1095,7 @@ async function runCli(args) {
627
1095
 
628
1096
  if (hookType === 'pre-commit') {
629
1097
  extraLogic = `
630
- # gcloud-secrets: auto-sync .env to Secret Manager
1098
+ # gcloud-secrets: auto-sync .env to Drive
631
1099
  if command -v gcloud-secrets >/dev/null 2>&1; then
632
1100
  gcloud-secrets pre-commit
633
1101
  fi
@@ -690,18 +1158,22 @@ exit 0
690
1158
  }
691
1159
 
692
1160
  default:
693
- console.log(`gcloud-secrets - GCP Secret Manager CLI
1161
+ console.log(`gcloud-secrets - シークレット管理 CLI (Google Drive + age 暗号化)
694
1162
 
695
1163
  使い方:
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 アンインストール
1164
+ gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>]
1165
+ 初期設定 (OAuth + age 鍵 + Drive フォルダ)
1166
+ gcloud-secrets reauth OAuth token 再認証のみ (config は保持)
1167
+ gcloud-secrets list [folder] [--env <env>] 一覧表示
1168
+ gcloud-secrets pull [folder] [--env <env>] シークレットを取得
1169
+ gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
1170
+ gcloud-secrets scan [basePath] [--env <env>] Git リポジトリの .env 同期状況をスキャン
1171
+ gcloud-secrets search <keyword> [--env <env>] 値から逆引き検索
1172
+ gcloud-secrets pre-commit .env 自動同期 (git hook 用)
1173
+ gcloud-secrets key backup age 秘密鍵を暗号化して Drive にバックアップ
1174
+ gcloud-secrets key restore Drive から age 秘密鍵を復元
1175
+ gcloud-secrets hook install グローバル git hook インストール
1176
+ gcloud-secrets hook uninstall グローバル git hook アンインストール
705
1177
 
706
1178
  オプション:
707
1179
  --env, -e <env> 環境を指定 (dev, staging, prod など)