@purpleschool/infisical-env 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -0
- package/src/cli.js +363 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@purpleschool/infisical-env",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"purple-env": "./src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node ./src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"picocolors": "^1.0.1",
|
|
13
|
+
"yaml": "^2.4.5"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import readline from "node:readline/promises";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
const ALLOWED_ENVS = ["stage", "stage-2"];
|
|
15
|
+
const DEFAULT_INFISICAL_BASE_URL = "https://secret.purplecode.ru";
|
|
16
|
+
const DEFAULT_INFISICAL_CLIENT_ID = "f3e0d60c-a602-4296-bd03-3be228e89165";
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
try {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
22
|
+
printHelp();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
27
|
+
const packageJson = readJson(path.join(repoRoot, "package.json"));
|
|
28
|
+
|
|
29
|
+
const projectId = resolveInfisicalProject(packageJson);
|
|
30
|
+
const envName = await resolveEnvName(args);
|
|
31
|
+
|
|
32
|
+
const deploymentPath = resolveDeploymentPath(repoRoot);
|
|
33
|
+
const envRefs = collectEnvRefs(deploymentPath);
|
|
34
|
+
|
|
35
|
+
if (envRefs.length === 0) {
|
|
36
|
+
throw new Error(`No env refs found in ${deploymentPath}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const values = await resolveEnvValues({
|
|
40
|
+
projectId,
|
|
41
|
+
envName,
|
|
42
|
+
envRefs,
|
|
43
|
+
repoRoot,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const envFilePath = path.join(repoRoot, ".env");
|
|
47
|
+
const lines = values.map((item) => `${item.name}=${item.value}`);
|
|
48
|
+
fs.writeFileSync(envFilePath, lines.join(os.EOL), "utf8");
|
|
49
|
+
|
|
50
|
+
console.log(pc.green(`.env written to ${envFilePath}`));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(pc.red(formatError(err)));
|
|
53
|
+
console.error(pc.yellow("Run with --help for usage."));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printHelp() {
|
|
59
|
+
console.log(
|
|
60
|
+
[
|
|
61
|
+
pc.bold("Usage:"),
|
|
62
|
+
" purple-env [--env stage|stage-2]",
|
|
63
|
+
"",
|
|
64
|
+
pc.bold("Options:"),
|
|
65
|
+
" --env <name> Environment name (stage or stage-2)",
|
|
66
|
+
" -h, --help Show this help",
|
|
67
|
+
"",
|
|
68
|
+
pc.bold("Environment variables:"),
|
|
69
|
+
" INFISICAL_CLIENT_SECRET Required (machine identity secret)",
|
|
70
|
+
" INFISICAL_CLIENT_ID Optional override",
|
|
71
|
+
" INFISICAL_BASE_URL Optional override (default https://secret.purplecode.ru)",
|
|
72
|
+
" INFISICAL_MOCK_PATH Optional mock json path for offline testing",
|
|
73
|
+
].join(os.EOL)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findRepoRoot(startDir) {
|
|
78
|
+
let current = startDir;
|
|
79
|
+
while (true) {
|
|
80
|
+
const candidate = path.join(current, "package.json");
|
|
81
|
+
if (fs.existsSync(candidate)) {
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
const parent = path.dirname(current);
|
|
85
|
+
if (parent === current) {
|
|
86
|
+
throw new Error("package.json not found in any parent directory");
|
|
87
|
+
}
|
|
88
|
+
current = parent;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readJson(filePath) {
|
|
93
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveInfisicalProject(packageJson) {
|
|
98
|
+
const name = typeof packageJson.name === "string" ? packageJson.name.toLowerCase() : "";
|
|
99
|
+
if (name.includes("rugpt")) {
|
|
100
|
+
return "48854e39-7129-4786-87ac-cfcce296991c";
|
|
101
|
+
}
|
|
102
|
+
if (name.includes("studdy")) {
|
|
103
|
+
return "b4b0abbd-f8e6-428a-a296-6289369ca8dd";
|
|
104
|
+
}
|
|
105
|
+
if (name.includes("multisite")) {
|
|
106
|
+
return "6531c08b-45e0-4d75-a8ab-08d14d0387bf";
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Unable to resolve Infisical project from package.json name. Expected name to include rugpt, studdy, or multisite."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function resolveEnvName(args) {
|
|
114
|
+
const flagValue = parseEnvFlag(args);
|
|
115
|
+
if (flagValue) {
|
|
116
|
+
return validateEnv(flagValue);
|
|
117
|
+
}
|
|
118
|
+
return promptEnv();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseEnvFlag(args) {
|
|
122
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
123
|
+
const arg = args[i];
|
|
124
|
+
if (arg === "--env") {
|
|
125
|
+
return args[i + 1] || "";
|
|
126
|
+
}
|
|
127
|
+
if (arg.startsWith("--env=")) {
|
|
128
|
+
return arg.slice("--env=".length);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateEnv(value) {
|
|
135
|
+
if (!ALLOWED_ENVS.includes(value)) {
|
|
136
|
+
throw new Error(`Invalid environment \"${value}\". Allowed: ${ALLOWED_ENVS.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function promptEnv() {
|
|
142
|
+
const rl = readline.createInterface({
|
|
143
|
+
input: process.stdin,
|
|
144
|
+
output: process.stdout,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
console.log(pc.bold("Select environment:"));
|
|
149
|
+
ALLOWED_ENVS.forEach((name, idx) => {
|
|
150
|
+
console.log(`${idx + 1}. ${name}`);
|
|
151
|
+
});
|
|
152
|
+
const answer = await rl.question("Enter number: ");
|
|
153
|
+
const index = Number(answer.trim()) - 1;
|
|
154
|
+
if (!Number.isInteger(index) || index < 0 || index >= ALLOWED_ENVS.length) {
|
|
155
|
+
throw new Error("Invalid environment selection");
|
|
156
|
+
}
|
|
157
|
+
return ALLOWED_ENVS[index];
|
|
158
|
+
} finally {
|
|
159
|
+
rl.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveDeploymentPath(repoRoot) {
|
|
164
|
+
const candidates = [
|
|
165
|
+
path.join(repoRoot, ".kube", "deployment.yml"),
|
|
166
|
+
path.join(repoRoot, ".kube", "deployment.yaml"),
|
|
167
|
+
path.join(repoRoot, "deployment.yml"),
|
|
168
|
+
path.join(repoRoot, "deployment.yaml"),
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
if (fs.existsSync(candidate)) {
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw new Error("Deployment file not found. Expected .kube/deployment.yml or .kube/deployment.yaml");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function collectEnvRefs(deploymentPath) {
|
|
181
|
+
const raw = fs.readFileSync(deploymentPath, "utf8");
|
|
182
|
+
const docs = YAML.parseAllDocuments(raw);
|
|
183
|
+
const refs = [];
|
|
184
|
+
|
|
185
|
+
for (const doc of docs) {
|
|
186
|
+
const data = doc.toJSON();
|
|
187
|
+
const containers = data?.spec?.template?.spec?.containers || [];
|
|
188
|
+
for (const container of containers) {
|
|
189
|
+
const envList = container?.env || [];
|
|
190
|
+
for (const envItem of envList) {
|
|
191
|
+
const name = envItem?.name;
|
|
192
|
+
const secretKeyRef = envItem?.valueFrom?.secretKeyRef;
|
|
193
|
+
const folder = secretKeyRef?.name;
|
|
194
|
+
const key = secretKeyRef?.key;
|
|
195
|
+
if (name && folder && key) {
|
|
196
|
+
refs.push({ name, folder, key });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return refs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function resolveEnvValues({ projectId, envName, envRefs, repoRoot }) {
|
|
206
|
+
const mockPath = process.env.INFISICAL_MOCK_PATH;
|
|
207
|
+
if (mockPath) {
|
|
208
|
+
return resolveFromMock({ projectId, envName, envRefs, mockPath, repoRoot });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const clientId = process.env.INFISICAL_CLIENT_ID || DEFAULT_INFISICAL_CLIENT_ID;
|
|
212
|
+
const clientSecret = process.env.INFISICAL_CLIENT_SECRET;
|
|
213
|
+
if (!clientId || !clientSecret) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"Missing Infisical credentials. Set INFISICAL_CLIENT_SECRET or use INFISICAL_MOCK_PATH."
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const baseUrl = normalizeBaseUrl(process.env.INFISICAL_BASE_URL || DEFAULT_INFISICAL_BASE_URL);
|
|
220
|
+
const accessToken = await loginUniversalAuth({ baseUrl, clientId, clientSecret });
|
|
221
|
+
|
|
222
|
+
return fetchSecretsFromInfisical({ baseUrl, accessToken, projectId, envName, envRefs });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveFromMock({ projectId, envName, envRefs, mockPath, repoRoot }) {
|
|
226
|
+
const absPath = path.isAbsolute(mockPath) ? mockPath : path.join(repoRoot, mockPath);
|
|
227
|
+
if (!fs.existsSync(absPath)) {
|
|
228
|
+
throw new Error(`Mock file not found: ${absPath}`);
|
|
229
|
+
}
|
|
230
|
+
const data = readJson(absPath);
|
|
231
|
+
const projectData = data?.projects?.[projectId];
|
|
232
|
+
if (!projectData) {
|
|
233
|
+
throw new Error(`Project not found in mock: ${projectId}`);
|
|
234
|
+
}
|
|
235
|
+
const envData = projectData?.[envName];
|
|
236
|
+
if (!envData) {
|
|
237
|
+
throw new Error(`Environment not found in mock: ${envName}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return envRefs.map((ref) => {
|
|
241
|
+
const folderData = envData?.[ref.folder];
|
|
242
|
+
const value = folderData?.[ref.key];
|
|
243
|
+
if (value === undefined || value === null) {
|
|
244
|
+
throw new Error(`Missing value for ${ref.folder}.${ref.key}`);
|
|
245
|
+
}
|
|
246
|
+
return { name: ref.name, value: String(value) };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatError(err) {
|
|
251
|
+
if (err instanceof Error) {
|
|
252
|
+
return err.message;
|
|
253
|
+
}
|
|
254
|
+
return String(err);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeBaseUrl(value) {
|
|
258
|
+
return value.replace(/\/+$/, "");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function loginUniversalAuth({ baseUrl, clientId, clientSecret }) {
|
|
262
|
+
const url = `${baseUrl}/api/v1/auth/universal-auth/login`;
|
|
263
|
+
const body = new URLSearchParams();
|
|
264
|
+
body.set("clientId", clientId);
|
|
265
|
+
body.set("clientSecret", clientSecret);
|
|
266
|
+
|
|
267
|
+
const res = await fetch(url, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
271
|
+
},
|
|
272
|
+
body,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const text = await safeReadText(res);
|
|
277
|
+
throw new Error(`Infisical login failed (${res.status}). ${text}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const data = await res.json();
|
|
281
|
+
if (!data?.accessToken) {
|
|
282
|
+
throw new Error("Infisical login response missing accessToken.");
|
|
283
|
+
}
|
|
284
|
+
return data.accessToken;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function fetchSecretsFromInfisical({ baseUrl, accessToken, projectId, envName, envRefs }) {
|
|
288
|
+
const refsByFolder = new Map();
|
|
289
|
+
for (const ref of envRefs) {
|
|
290
|
+
if (!refsByFolder.has(ref.folder)) {
|
|
291
|
+
refsByFolder.set(ref.folder, []);
|
|
292
|
+
}
|
|
293
|
+
refsByFolder.get(ref.folder).push(ref);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const secretCache = new Map();
|
|
297
|
+
for (const [folder, refs] of refsByFolder.entries()) {
|
|
298
|
+
const secretPath = folder.startsWith("/") ? folder : `/${folder}`;
|
|
299
|
+
const secrets = await listSecrets({
|
|
300
|
+
baseUrl,
|
|
301
|
+
accessToken,
|
|
302
|
+
projectId,
|
|
303
|
+
envName,
|
|
304
|
+
secretPath,
|
|
305
|
+
});
|
|
306
|
+
const map = new Map();
|
|
307
|
+
for (const item of secrets) {
|
|
308
|
+
if (item?.secretKey != null) {
|
|
309
|
+
map.set(item.secretKey, item.secretValue);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
secretCache.set(folder, map);
|
|
313
|
+
|
|
314
|
+
for (const ref of refs) {
|
|
315
|
+
if (!map.has(ref.key)) {
|
|
316
|
+
throw new Error(`Missing value for ${folder}.${ref.key} in environment ${envName}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return envRefs.map((ref) => {
|
|
322
|
+
const folderMap = secretCache.get(ref.folder);
|
|
323
|
+
const value = folderMap?.get(ref.key);
|
|
324
|
+
return { name: ref.name, value: String(value) };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function listSecrets({ baseUrl, accessToken, projectId, envName, secretPath }) {
|
|
329
|
+
const params = new URLSearchParams({
|
|
330
|
+
projectId,
|
|
331
|
+
environment: envName,
|
|
332
|
+
secretPath,
|
|
333
|
+
viewSecretValue: "true",
|
|
334
|
+
expandSecretReferences: "true",
|
|
335
|
+
recursive: "false",
|
|
336
|
+
includeImports: "true",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const url = `${baseUrl}/api/v4/secrets?${params.toString()}`;
|
|
340
|
+
const res = await fetch(url, {
|
|
341
|
+
headers: {
|
|
342
|
+
Authorization: `Bearer ${accessToken}`,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (!res.ok) {
|
|
347
|
+
const text = await safeReadText(res);
|
|
348
|
+
throw new Error(`Infisical secrets list failed (${res.status}) for ${secretPath}. ${text}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const data = await res.json();
|
|
352
|
+
return Array.isArray(data?.secrets) ? data.secrets : [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function safeReadText(res) {
|
|
356
|
+
try {
|
|
357
|
+
return await res.text();
|
|
358
|
+
} catch {
|
|
359
|
+
return "";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await main();
|