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