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