@skaile/forge-settings 0.0.0-stage → 1.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/module.d.mts +17 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +23 -0
- package/dist/runtime/ai-sdk/index.d.ts +45 -0
- package/dist/runtime/ai-sdk/index.js +39 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
- package/dist/runtime/editor/index.d.ts +58 -0
- package/dist/runtime/editor/index.js +83 -0
- package/dist/runtime/model/errors.d.ts +24 -0
- package/dist/runtime/model/errors.js +10 -0
- package/dist/runtime/server/credential-policy.d.ts +60 -0
- package/dist/runtime/server/credential-policy.js +64 -0
- package/dist/runtime/server/credential-store.d.ts +184 -0
- package/dist/runtime/server/credential-store.js +234 -0
- package/dist/runtime/server/env-layers.d.ts +62 -0
- package/dist/runtime/server/env-layers.js +185 -0
- package/dist/runtime/server/env.d.ts +30 -0
- package/dist/runtime/server/env.js +15 -0
- package/dist/runtime/server/harness-settings.d.ts +150 -0
- package/dist/runtime/server/harness-settings.js +166 -0
- package/dist/runtime/server/index.d.ts +23 -0
- package/dist/runtime/server/index.js +14 -0
- package/dist/runtime/server/provider-discovery.d.ts +73 -0
- package/dist/runtime/server/provider-discovery.js +77 -0
- package/dist/runtime/server/provider-layers.d.ts +27 -0
- package/dist/runtime/server/provider-layers.js +37 -0
- package/dist/runtime/server/provider-service.d.ts +126 -0
- package/dist/runtime/server/provider-service.js +87 -0
- package/dist/runtime/server/provider-settings.d.ts +78 -0
- package/dist/runtime/server/provider-settings.js +81 -0
- package/dist/runtime/server/provider-write.d.ts +154 -0
- package/dist/runtime/server/provider-write.js +223 -0
- package/dist/runtime/server/settings-defaults.d.ts +26 -0
- package/dist/runtime/server/settings-defaults.js +46 -0
- package/dist/runtime/shared/env-vars.d.ts +55 -0
- package/dist/runtime/shared/env-vars.js +22 -0
- package/dist/runtime/shared/harness-providers.d.ts +62 -0
- package/dist/runtime/shared/harness-providers.js +38 -0
- package/dist/runtime/shared/harnesses.d.ts +19 -0
- package/dist/runtime/shared/harnesses.js +15 -0
- package/dist/runtime/shared/provider-registry.d.ts +255 -0
- package/dist/runtime/shared/provider-registry.js +112 -0
- package/dist/runtime/shared/settings-types.d.ts +84 -0
- package/dist/runtime/shared/settings-types.js +0 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/vue/index.d.ts +14 -0
- package/dist/runtime/vue/index.js +6 -0
- package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
- package/dist/runtime/vue/settings-modal-scope.js +2 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
- package/dist/runtime/vue/useProvidersPanel.js +655 -0
- package/dist/types.d.mts +9 -0
- package/package.json +117 -4
- package/src/runtime/ai-sdk/index.ts +91 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
- package/src/runtime/editor/index.ts +157 -0
- package/src/runtime/model/errors.ts +31 -0
- package/src/runtime/server/credential-policy.ts +137 -0
- package/src/runtime/server/credential-store.ts +473 -0
- package/src/runtime/server/env-layers.ts +290 -0
- package/src/runtime/server/env.ts +46 -0
- package/src/runtime/server/harness-settings.ts +373 -0
- package/src/runtime/server/index.ts +24 -0
- package/src/runtime/server/provider-discovery.ts +164 -0
- package/src/runtime/server/provider-layers.ts +70 -0
- package/src/runtime/server/provider-service.ts +244 -0
- package/src/runtime/server/provider-settings.ts +192 -0
- package/src/runtime/server/provider-write.ts +438 -0
- package/src/runtime/server/settings-defaults.ts +87 -0
- package/src/runtime/shared/env-vars.ts +82 -0
- package/src/runtime/shared/harness-providers.ts +118 -0
- package/src/runtime/shared/harnesses.ts +34 -0
- package/src/runtime/shared/provider-registry.ts +391 -0
- package/src/runtime/shared/settings-types.ts +87 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/vue/index.ts +15 -0
- package/src/runtime/vue/settings-modal-scope.ts +66 -0
- package/src/runtime/vue/useProvidersPanel.ts +1087 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered `.env` files — the server half of the settings modal's Providers
|
|
3
|
+
* tab (credential picker, Enter a key, environment disclosure).
|
|
4
|
+
*
|
|
5
|
+
* A registry entry references its secret as `env:NAME`; the host resolves that
|
|
6
|
+
* name against a layered environment where the nearest layer wins:
|
|
7
|
+
* `process` (boot env) < `instance` (`$FORGE_HOME/.env`) < `workspace`
|
|
8
|
+
* (`<workspace root>/.env`). This module reads and edits those files and builds
|
|
9
|
+
* the document the browser sees — names, masked hints and lengths, never
|
|
10
|
+
* values.
|
|
11
|
+
*
|
|
12
|
+
* The vocabulary (layer ids, name rules, document shapes) lives in the
|
|
13
|
+
* browser-safe `utils/env-vars.ts` and is re-exported here, so client and
|
|
14
|
+
* server refuse the same names from one source.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
|
|
20
|
+
import type { AIProviderRegistryView } from "../shared/provider-registry";
|
|
21
|
+
import { parseCredentialRef, registryEntries } from "../shared/provider-registry";
|
|
22
|
+
import {
|
|
23
|
+
ENV_LAYER_ORDER,
|
|
24
|
+
type EnvLayerId,
|
|
25
|
+
type EnvSettingsDocument,
|
|
26
|
+
type EnvSettingsMissing,
|
|
27
|
+
type EnvSettingsUpdate,
|
|
28
|
+
type EnvVarView,
|
|
29
|
+
validateEnvVarName,
|
|
30
|
+
} from "../shared/env-vars";
|
|
31
|
+
|
|
32
|
+
export * from "../shared/env-vars";
|
|
33
|
+
|
|
34
|
+
// ── Parsing ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** `KEY=VALUE`, optionally prefixed with `export `. Group 1 = key, group 2 = raw value. */
|
|
37
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/;
|
|
38
|
+
|
|
39
|
+
/** Characters that force a written value into double quotes. */
|
|
40
|
+
const NEEDS_QUOTES = /[\s#"'=\\]/;
|
|
41
|
+
|
|
42
|
+
/** Value part of one assignment line, quotes and inline comments resolved. */
|
|
43
|
+
function parseValue(raw: string): string {
|
|
44
|
+
const value = raw.trim();
|
|
45
|
+
if (value.startsWith('"')) {
|
|
46
|
+
for (let i = 1; i < value.length; i++) {
|
|
47
|
+
if (value[i] === "\\") {
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value[i] === '"') {
|
|
52
|
+
return value
|
|
53
|
+
.slice(1, i)
|
|
54
|
+
.replace(/\\([nr"\\])/g, (_, ch: string) => (ch === "n" ? "\n" : ch === "r" ? "\r" : ch));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (value.startsWith("'")) {
|
|
60
|
+
const end = value.indexOf("'", 1);
|
|
61
|
+
return end === -1 ? value : value.slice(1, end);
|
|
62
|
+
}
|
|
63
|
+
const comment = value.search(/\s#/);
|
|
64
|
+
return (comment === -1 ? value : value.slice(0, comment)).trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse `.env` text: blank and `#` lines skipped, optional `export ` prefix,
|
|
69
|
+
* single-quoted values literal, double-quoted values unescape `\n`, `\"`, `\\`,
|
|
70
|
+
* unquoted values lose an inline ` #` comment. A later duplicate wins.
|
|
71
|
+
*/
|
|
72
|
+
export function parseDotenv(text: string): Record<string, string> {
|
|
73
|
+
const env: Record<string, string> = {};
|
|
74
|
+
// Blank and `#` lines never match: a key must start the (trimmed) line.
|
|
75
|
+
for (const line of text.split(/\r?\n/)) {
|
|
76
|
+
const match = ASSIGNMENT.exec(line);
|
|
77
|
+
if (match) env[match[1]] = parseValue(match[2]);
|
|
78
|
+
}
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parsed `.env` file; `{}` when it is missing or unreadable. */
|
|
83
|
+
export function readDotenvFile(path: string): Record<string, string> {
|
|
84
|
+
try {
|
|
85
|
+
return parseDotenv(readFileSync(path, "utf-8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Writing ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** One value as it is written after `KEY=` — round-trips through {@link parseDotenv}. */
|
|
94
|
+
function serializeValue(value: string): string {
|
|
95
|
+
if (!NEEDS_QUOTES.test(value)) return value;
|
|
96
|
+
const escaped = value
|
|
97
|
+
.replace(/\\/g, "\\\\")
|
|
98
|
+
.replace(/"/g, '\\"')
|
|
99
|
+
.replace(/\n/g, "\\n")
|
|
100
|
+
.replace(/\r/g, "\\r");
|
|
101
|
+
return `"${escaped}"`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readTextIfExists(path: string): string {
|
|
105
|
+
try {
|
|
106
|
+
return readFileSync(path, "utf-8");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Write `content` to `path` atomically (tmp file + rename), mode 0600. */
|
|
114
|
+
function writeFileAtomic(path: string, content: string): void {
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
119
|
+
renameSync(tmp, path);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
rmSync(tmp, { force: true });
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
chmodSync(path, 0o600);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply `patch` to the `.env` file at `path`, preserving every line it does not
|
|
129
|
+
* touch. A patched key's first `KEY=` / `export KEY=` line is rewritten in
|
|
130
|
+
* place and its later duplicates dropped; `null` deletes the key; keys the file
|
|
131
|
+
* lacks are appended. The file is created (parents included) when missing and
|
|
132
|
+
* always left at mode 0600.
|
|
133
|
+
*/
|
|
134
|
+
export function updateDotenvFile(path: string, patch: Record<string, string | null>): void {
|
|
135
|
+
const text = readTextIfExists(path);
|
|
136
|
+
const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split("\n");
|
|
137
|
+
const written = new Set<string>();
|
|
138
|
+
const out: string[] = [];
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
const key = ASSIGNMENT.exec(line)?.[1];
|
|
142
|
+
if (key === undefined || !Object.hasOwn(patch, key)) {
|
|
143
|
+
out.push(line);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (written.has(key)) continue;
|
|
147
|
+
written.add(key);
|
|
148
|
+
const value = patch[key];
|
|
149
|
+
if (value === null) continue;
|
|
150
|
+
const prefix = /^\s*export\s/.test(line) ? "export " : "";
|
|
151
|
+
out.push(`${prefix}${key}=${serializeValue(value)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
155
|
+
if (written.has(key) || value === null) continue;
|
|
156
|
+
out.push(`${key}=${serializeValue(value)}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
writeFileAtomic(path, out.length > 0 ? `${out.join("\n")}\n` : "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `.gitignore` lines that already keep a directory's `.env` out of git. */
|
|
163
|
+
const DOTENV_IGNORE_LINES: Record<string, true> = {
|
|
164
|
+
".env": true,
|
|
165
|
+
"/.env": true,
|
|
166
|
+
"*.env": true,
|
|
167
|
+
".env*": true,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Make sure `<dir>/.gitignore` ignores `.env`: appends the line unless an
|
|
172
|
+
* equivalent pattern is already present, creating the file when missing.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureDotenvIgnored(dir: string): void {
|
|
175
|
+
const path = join(dir, ".gitignore");
|
|
176
|
+
const text = readTextIfExists(path);
|
|
177
|
+
if (text.split(/\r?\n/).some((line) => DOTENV_IGNORE_LINES[line.trim()])) return;
|
|
178
|
+
const separator = text !== "" && !text.endsWith("\n") ? "\n" : "";
|
|
179
|
+
writeFileSync(path, `${text}${separator}.env\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Settings document ──────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/** Input of {@link buildEnvSettingsDocument}. */
|
|
185
|
+
export interface EnvSettingsDocumentInput {
|
|
186
|
+
scope: "instance" | "workspace";
|
|
187
|
+
editable: boolean;
|
|
188
|
+
/** The layers visible from this scope; absent layers are ignored. */
|
|
189
|
+
layers: Partial<Record<EnvLayerId, Record<string, string | undefined>>>;
|
|
190
|
+
/** Registry whose `env:` references are reported (`referencedBy`, `missing`). */
|
|
191
|
+
registry: AIProviderRegistryView;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The browser's view of a scope's environment. Lists every name an editable
|
|
196
|
+
* layer (instance, workspace) holds, every name a registry `env:` reference
|
|
197
|
+
* uses, and every catalog preset's conventional key name that some layer sets
|
|
198
|
+
* — so the credential picker can tell a set `OPENROUTER_API_KEY` from an unset
|
|
199
|
+
* one. Other process variables stay unlisted. Values never leave: each row
|
|
200
|
+
* carries only a hint and a length.
|
|
201
|
+
*/
|
|
202
|
+
export function buildEnvSettingsDocument(input: EnvSettingsDocumentInput): EnvSettingsDocument {
|
|
203
|
+
const { layers } = input;
|
|
204
|
+
|
|
205
|
+
const referencedBy = new Map<string, string[]>();
|
|
206
|
+
for (const [id, entry] of registryEntries(input.registry)) {
|
|
207
|
+
const ref = entry.credential?.ref;
|
|
208
|
+
const parsed = ref ? parseCredentialRef(ref) : undefined;
|
|
209
|
+
if (parsed?.form !== "env") continue;
|
|
210
|
+
const ids = referencedBy.get(parsed.name) ?? [];
|
|
211
|
+
ids.push(id);
|
|
212
|
+
referencedBy.set(parsed.name, ids);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const names = new Set<string>(referencedBy.keys());
|
|
216
|
+
for (const layer of ["instance", "workspace"] as const) {
|
|
217
|
+
for (const name of Object.keys(layers[layer] ?? {})) names.add(name);
|
|
218
|
+
}
|
|
219
|
+
for (const preset of Object.values(AI_PROVIDER_PRESETS)) {
|
|
220
|
+
const name = preset.credential.env;
|
|
221
|
+
if (name && ENV_LAYER_ORDER.some((layer) => layers[layer]?.[name])) names.add(name);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const vars: EnvVarView[] = [];
|
|
225
|
+
const missing: EnvSettingsMissing[] = [];
|
|
226
|
+
for (const name of [...names].sort()) {
|
|
227
|
+
const refs = [...(referencedBy.get(name) ?? [])].sort();
|
|
228
|
+
const defining = ENV_LAYER_ORDER.filter((layer) => {
|
|
229
|
+
const value = layers[layer]?.[name];
|
|
230
|
+
return value !== undefined && value !== "";
|
|
231
|
+
});
|
|
232
|
+
const source = defining.at(-1);
|
|
233
|
+
if (source === undefined) {
|
|
234
|
+
if (refs.length > 0) missing.push({ name, referencedBy: refs });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const value = layers[source]?.[name] ?? "";
|
|
238
|
+
vars.push({
|
|
239
|
+
name,
|
|
240
|
+
hint: value.length >= 8 ? value.slice(-4) : "",
|
|
241
|
+
length: value.length,
|
|
242
|
+
source,
|
|
243
|
+
shadows: defining.slice(0, -1),
|
|
244
|
+
referencedBy: refs,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { scope: input.scope, editable: input.editable, layer: input.scope, vars, missing };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Update validation ──────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
254
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate a `PUT <environmentUrl>` body. Returns the normalized update, or a
|
|
259
|
+
* refusal string suitable as an HTTP 400 status message.
|
|
260
|
+
*/
|
|
261
|
+
export function validateEnvSettingsUpdate(body: unknown): EnvSettingsUpdate | string {
|
|
262
|
+
if (!isPlainObject(body)) return "Body must be an object with `set` and/or `unset`";
|
|
263
|
+
|
|
264
|
+
const set: Record<string, string> = {};
|
|
265
|
+
if (body.set !== undefined) {
|
|
266
|
+
if (!isPlainObject(body.set)) return "`set` must be an object of variable names to values";
|
|
267
|
+
for (const [name, value] of Object.entries(body.set)) {
|
|
268
|
+
const refusal = validateEnvVarName(name);
|
|
269
|
+
if (refusal) return refusal;
|
|
270
|
+
if (typeof value !== "string") return `Value of "${name}" must be a string`;
|
|
271
|
+
if (/[\r\n]/.test(value)) return `Value of "${name}" must be a single line`;
|
|
272
|
+
set[name] = value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const unset: string[] = [];
|
|
277
|
+
if (body.unset !== undefined) {
|
|
278
|
+
if (!Array.isArray(body.unset)) return "`unset` must be an array of variable names";
|
|
279
|
+
for (const name of body.unset) {
|
|
280
|
+
if (typeof name !== "string") return "`unset` must be an array of variable names";
|
|
281
|
+
const refusal = validateEnvVarName(name);
|
|
282
|
+
if (refusal) return refusal;
|
|
283
|
+
if (Object.hasOwn(set, name)) return `"${name}" cannot be both set and unset`;
|
|
284
|
+
if (!unset.includes(name)) unset.push(name);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (Object.keys(set).length === 0 && unset.length === 0) return "Nothing to change";
|
|
289
|
+
return { set, unset };
|
|
290
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env file loading utilities for forge apps.
|
|
3
|
+
*
|
|
4
|
+
* Nuxt's built-in dotenv only injects NUXT_* variables into runtime config.
|
|
5
|
+
* API keys and provider preferences (ANTHROPIC_API_KEY, PREFERRED_PROVIDER,
|
|
6
|
+
* PREFERRED_MODEL, etc.) are plain env vars read directly via process.env, so
|
|
7
|
+
* forge apps need to load them explicitly at server startup.
|
|
8
|
+
*
|
|
9
|
+
* Usage pattern (in a Nitro plugin named 00.env-load.ts):
|
|
10
|
+
*
|
|
11
|
+
* import { injectEnvFiles } from '@skaile/forge-settings/server'
|
|
12
|
+
* injectEnvFiles([appRoot])
|
|
13
|
+
*
|
|
14
|
+
* ~/.env is always included as the lowest-priority source automatically.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { readDotenvFile } from "./env-layers";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse the `.env` file in the given directory (see `parseDotenv` for the
|
|
23
|
+
* syntax). Returns an empty object if the file does not exist or cannot be read.
|
|
24
|
+
*/
|
|
25
|
+
export function loadEnvFile(dir: string): Record<string, string> {
|
|
26
|
+
return readDotenvFile(path.join(dir, ".env"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load `.env` files and inject missing keys into process.env.
|
|
31
|
+
*
|
|
32
|
+
* Directories are merged in order — later directories take precedence.
|
|
33
|
+
* `~/.env` is always prepended as the lowest-priority source.
|
|
34
|
+
* Real environment variables (already in process.env) are never overwritten.
|
|
35
|
+
*
|
|
36
|
+
* @param appDirs - App-specific directories to scan (e.g. app root, project root)
|
|
37
|
+
*/
|
|
38
|
+
export function injectEnvFiles(appDirs: string[]): void {
|
|
39
|
+
const merged: Record<string, string> = {};
|
|
40
|
+
for (const dir of [homedir(), ...appDirs]) {
|
|
41
|
+
Object.assign(merged, loadEnvFile(dir));
|
|
42
|
+
}
|
|
43
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
44
|
+
if (!process.env[k] && v) process.env[k] = v;
|
|
45
|
+
}
|
|
46
|
+
}
|