@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.
- package/cli.js +764 -292
- package/package.json +6 -5
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
526
|
+
const driveFolderId = parsed.positional[1];
|
|
187
527
|
const defaultEnv = parsed.env || "dev";
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
202
|
-
const [secrets] = await client.listSecrets({ parent });
|
|
651
|
+
const drive = await getDriveClient(config);
|
|
203
652
|
|
|
204
653
|
if (!folder) {
|
|
205
|
-
// フォルダ一覧
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
218
|
-
const
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
709
|
+
|
|
710
|
+
const file = await findEnvAgeFile(drive, folderObj.id, targetEnv);
|
|
711
|
+
if (!file) {
|
|
259
712
|
console.error(`警告: ${folder} (${targetEnv}) にシークレットが見つかりません`);
|
|
260
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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;
|
|
753
|
+
const filterEnv = parsed.env;
|
|
321
754
|
const repos = findGitRepositories(basePath, 5);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
367
|
-
const envLabel = checkEnv || "(default)";
|
|
805
|
+
const mapKey = `${normalizedFolder}|${checkEnv}`;
|
|
806
|
+
const remoteEntries = remoteData.get(mapKey);
|
|
368
807
|
|
|
369
|
-
if (
|
|
370
|
-
results.push({ status: "NEW", repo: repoName, file: envFile.filename, env:
|
|
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 (!
|
|
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
|
|
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:
|
|
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:
|
|
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(`===
|
|
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.
|
|
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
|
-
//
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
564
|
-
const
|
|
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 ${
|
|
982
|
+
console.log(`\nScanning ${targetFiles.length} files...\n`);
|
|
569
983
|
|
|
570
984
|
const results = await Promise.all(
|
|
571
|
-
|
|
985
|
+
targetFiles.map(async (rf) => {
|
|
572
986
|
try {
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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.
|
|
597
|
-
const folders = new Set(matches.map(
|
|
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
|
|
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 -
|
|
1161
|
+
console.log(`gcloud-secrets - シークレット管理 CLI (Google Drive + age 暗号化)
|
|
694
1162
|
|
|
695
1163
|
使い方:
|
|
696
|
-
gcloud-secrets init
|
|
697
|
-
|
|
698
|
-
gcloud-secrets
|
|
699
|
-
gcloud-secrets
|
|
700
|
-
gcloud-secrets
|
|
701
|
-
gcloud-secrets
|
|
702
|
-
gcloud-secrets
|
|
703
|
-
gcloud-secrets
|
|
704
|
-
gcloud-secrets
|
|
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 など)
|