opencode-dotenv 0.4.1 → 0.5.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 +20 -7
- package/dist/index.js +221 -66
- package/package.json +28 -6
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
OpenCode plugin to load `.env` files at startup.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
- Load multiple `.env` files in order via config file
|
|
8
8
|
- Load `.env` from current working directory (optional)
|
|
9
9
|
- Override existing environment variables (later files override earlier ones)
|
|
@@ -11,7 +11,19 @@ OpenCode plugin to load `.env` files at startup.
|
|
|
11
11
|
- Prevents double loading with load guard
|
|
12
12
|
- JSONC config file format (supports comments and trailing commas)
|
|
13
13
|
- **Requires Bun runtime**
|
|
14
|
+
|
|
15
|
+
## Limitations
|
|
16
|
+
|
|
17
|
+
**Important:** This plugin loads AFTER OpenCode configuration is already parsed. Therefore:
|
|
18
|
+
|
|
19
|
+
1. **Cannot modify existing OpenCode config** - Variables loaded by this plugin cannot be referenced in `opencode.jsonc` using `{env:VAR}` syntax. OpenCode reads its config before plugins initialize.
|
|
20
|
+
|
|
21
|
+
2. **Only affects subsequent operations** - Loaded environment variables are only available to new chat sessions, tool calls, and operations that occur AFTER plugin initialization.
|
|
14
22
|
|
|
23
|
+
3. **No config reload capability** - Changing `.env` files while OpenCode is running will NOT trigger config re-parsing. To apply changes, restart OpenCode.
|
|
24
|
+
|
|
25
|
+
**Recommended approach:** Set environment variables in your shell profile (`.zshrc`, `.bashrc`) before starting OpenCode.
|
|
26
|
+
|
|
15
27
|
## Installation
|
|
16
28
|
|
|
17
29
|
Add to your `opencode.jsonc`:
|
|
@@ -126,18 +138,19 @@ Result:
|
|
|
126
138
|
4. No logging output
|
|
127
139
|
|
|
128
140
|
### Example .env files
|
|
129
|
-
|
|
141
|
+
|
|
130
142
|
`~/.config/opencode/.env`:
|
|
131
|
-
|
|
143
|
+
|
|
132
144
|
```bash
|
|
133
145
|
# OpenCode Dotenv Configuration
|
|
134
|
-
OPENCODE_API_KEY=your_api_key_here
|
|
135
|
-
OPENCODE_DEBUG=true
|
|
136
|
-
OPENCODE_MAX_TOKENS=100000
|
|
146
|
+
OPENCODE_API_KEY=your_api_key_here
|
|
147
|
+
OPENCODE_DEBUG=true
|
|
148
|
+
OPENCODE_MAX_TOKENS=100000
|
|
137
149
|
```
|
|
138
150
|
|
|
139
|
-
|
|
151
|
+
**Note:** This plugin cannot inject these variables into OpenCode's configuration loading process. To use `OPENCODE_API_KEY` in `opencode.jsonc`, set it in your shell profile (`~/.zshrc`, `~/.bashrc`) before starting OpenCode.
|
|
140
152
|
|
|
153
|
+
`./.env` (project-specific):
|
|
141
154
|
```bash
|
|
142
155
|
# Project-specific overrides
|
|
143
156
|
OPENCODE_DEBUG=false
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
// src/
|
|
2
|
+
// src/plugin.ts
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
4
5
|
|
|
5
6
|
// node_modules/jsonc-parser/lib/esm/impl/scanner.js
|
|
6
7
|
function createScanner(text, ignoreTrivia = false) {
|
|
@@ -806,9 +807,165 @@ var ParseErrorCode;
|
|
|
806
807
|
ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter";
|
|
807
808
|
})(ParseErrorCode || (ParseErrorCode = {}));
|
|
808
809
|
|
|
809
|
-
// src/
|
|
810
|
-
|
|
810
|
+
// src/profiler/profiler.ts
|
|
811
|
+
function percentile(sorted, p) {
|
|
812
|
+
if (sorted.length === 0)
|
|
813
|
+
return 0;
|
|
814
|
+
const index = Math.ceil(p / 100 * sorted.length) - 1;
|
|
815
|
+
return sorted[Math.max(0, index)];
|
|
816
|
+
}
|
|
817
|
+
function calculateStats(measurements) {
|
|
818
|
+
if (measurements.length === 0)
|
|
819
|
+
return null;
|
|
820
|
+
const sorted = [...measurements].sort((a, b) => a - b);
|
|
821
|
+
const total = sorted.reduce((sum, v) => sum + v, 0);
|
|
822
|
+
return {
|
|
823
|
+
count: sorted.length,
|
|
824
|
+
min: sorted[0],
|
|
825
|
+
max: sorted[sorted.length - 1],
|
|
826
|
+
avg: total / sorted.length,
|
|
827
|
+
p50: percentile(sorted, 50),
|
|
828
|
+
p95: percentile(sorted, 95),
|
|
829
|
+
p99: percentile(sorted, 99),
|
|
830
|
+
total
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
class Profiler {
|
|
835
|
+
marks = new Map;
|
|
836
|
+
measures = new Map;
|
|
837
|
+
fileMetrics = [];
|
|
838
|
+
initStartTime = null;
|
|
839
|
+
initEndTime = null;
|
|
840
|
+
initState = "idle";
|
|
841
|
+
configLoadTime = null;
|
|
842
|
+
configPath = null;
|
|
843
|
+
totalVars = 0;
|
|
844
|
+
startTime = performance.now();
|
|
845
|
+
mark(name) {
|
|
846
|
+
this.marks.set(name, performance.now());
|
|
847
|
+
}
|
|
848
|
+
measure(name, startMark) {
|
|
849
|
+
const start = this.marks.get(startMark);
|
|
850
|
+
if (start === undefined) {
|
|
851
|
+
return -1;
|
|
852
|
+
}
|
|
853
|
+
const duration = performance.now() - start;
|
|
854
|
+
const existing = this.measures.get(name) || [];
|
|
855
|
+
existing.push(duration);
|
|
856
|
+
this.measures.set(name, existing);
|
|
857
|
+
return duration;
|
|
858
|
+
}
|
|
859
|
+
record(name, duration) {
|
|
860
|
+
const existing = this.measures.get(name) || [];
|
|
861
|
+
existing.push(duration);
|
|
862
|
+
this.measures.set(name, existing);
|
|
863
|
+
}
|
|
864
|
+
getStats(name) {
|
|
865
|
+
const measurements = this.measures.get(name);
|
|
866
|
+
if (!measurements)
|
|
867
|
+
return null;
|
|
868
|
+
return calculateStats(measurements);
|
|
869
|
+
}
|
|
870
|
+
initStart() {
|
|
871
|
+
this.initStartTime = performance.now();
|
|
872
|
+
this.initState = "loading";
|
|
873
|
+
}
|
|
874
|
+
initComplete(state) {
|
|
875
|
+
this.initEndTime = performance.now();
|
|
876
|
+
this.initState = state;
|
|
877
|
+
}
|
|
878
|
+
recordConfigLoad(duration, path) {
|
|
879
|
+
this.configLoadTime = duration;
|
|
880
|
+
this.configPath = path;
|
|
881
|
+
}
|
|
882
|
+
recordFileLoad(path, duration, varCount, success, error) {
|
|
883
|
+
this.fileMetrics.push({
|
|
884
|
+
path,
|
|
885
|
+
duration,
|
|
886
|
+
varCount,
|
|
887
|
+
success,
|
|
888
|
+
error
|
|
889
|
+
});
|
|
890
|
+
if (success) {
|
|
891
|
+
this.totalVars += varCount;
|
|
892
|
+
this.record("file.load", duration);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
getInitState() {
|
|
896
|
+
return this.initState;
|
|
897
|
+
}
|
|
898
|
+
getInitDuration() {
|
|
899
|
+
if (this.initStartTime === null)
|
|
900
|
+
return null;
|
|
901
|
+
if (this.initEndTime !== null) {
|
|
902
|
+
return this.initEndTime - this.initStartTime;
|
|
903
|
+
}
|
|
904
|
+
return performance.now() - this.initStartTime;
|
|
905
|
+
}
|
|
906
|
+
getTotalVars() {
|
|
907
|
+
return this.totalVars;
|
|
908
|
+
}
|
|
909
|
+
getFileMetrics() {
|
|
910
|
+
return [...this.fileMetrics];
|
|
911
|
+
}
|
|
912
|
+
export() {
|
|
913
|
+
return {
|
|
914
|
+
timestamp: new Date().toISOString(),
|
|
915
|
+
uptime: performance.now() - this.startTime,
|
|
916
|
+
initialization: {
|
|
917
|
+
startTime: this.initStartTime || 0,
|
|
918
|
+
endTime: this.initEndTime,
|
|
919
|
+
duration: this.getInitDuration(),
|
|
920
|
+
state: this.initState
|
|
921
|
+
},
|
|
922
|
+
config: {
|
|
923
|
+
loadTime: this.configLoadTime,
|
|
924
|
+
path: this.configPath
|
|
925
|
+
},
|
|
926
|
+
files: {
|
|
927
|
+
loadTimes: this.getStats("file.load"),
|
|
928
|
+
details: this.fileMetrics,
|
|
929
|
+
totalVars: this.totalVars
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
reset() {
|
|
934
|
+
this.marks.clear();
|
|
935
|
+
this.measures.clear();
|
|
936
|
+
this.fileMetrics = [];
|
|
937
|
+
this.initStartTime = null;
|
|
938
|
+
this.initEndTime = null;
|
|
939
|
+
this.initState = "idle";
|
|
940
|
+
this.configLoadTime = null;
|
|
941
|
+
this.configPath = null;
|
|
942
|
+
this.totalVars = 0;
|
|
943
|
+
}
|
|
944
|
+
startTimer(name) {
|
|
945
|
+
const start = performance.now();
|
|
946
|
+
return () => {
|
|
947
|
+
const duration = performance.now() - start;
|
|
948
|
+
this.record(name, duration);
|
|
949
|
+
return duration;
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
var globalProfiler = new Profiler;
|
|
954
|
+
// src/plugin.ts
|
|
955
|
+
var CONFIG_NAME = "dotenv.jsonc";
|
|
956
|
+
var LOG_DIR = `${homedir()}/.local/share/opencode`;
|
|
957
|
+
var LOG_FILE = `${LOG_DIR}/dotenv.log`;
|
|
811
958
|
var LOAD_GUARD = "__opencodeDotenvLoaded";
|
|
959
|
+
var isTestEnv = !!process.env.BUN_TEST;
|
|
960
|
+
var loggingEnabled = false;
|
|
961
|
+
function log(message) {
|
|
962
|
+
if (!loggingEnabled || isTestEnv)
|
|
963
|
+
return;
|
|
964
|
+
const timestamp = new Date().toISOString();
|
|
965
|
+
const line = `[${timestamp}] ${message}
|
|
966
|
+
`;
|
|
967
|
+
mkdir(LOG_DIR, { recursive: true }).then(() => appendFile(LOG_FILE, line)).catch(() => {});
|
|
968
|
+
}
|
|
812
969
|
function parseDotenv(content) {
|
|
813
970
|
const result = {};
|
|
814
971
|
if (typeof content !== "string")
|
|
@@ -820,114 +977,112 @@ function parseDotenv(content) {
|
|
|
820
977
|
continue;
|
|
821
978
|
const match = trimmed.match(/^export\s+([^=]+)=(.*)$/);
|
|
822
979
|
const key = match ? match[1] : trimmed.split("=")[0];
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
parsedValue = parsedValue.slice(1, -1);
|
|
980
|
+
if (!key)
|
|
981
|
+
continue;
|
|
982
|
+
const value = match ? match[2] : trimmed.substring(key.length + 1);
|
|
983
|
+
if (value === undefined)
|
|
984
|
+
continue;
|
|
985
|
+
let parsedValue = value.trim();
|
|
986
|
+
if (!parsedValue.startsWith('"') && !parsedValue.startsWith("'")) {
|
|
987
|
+
const inlineCommentIndex = parsedValue.indexOf(" #");
|
|
988
|
+
if (inlineCommentIndex !== -1) {
|
|
989
|
+
parsedValue = parsedValue.substring(0, inlineCommentIndex).trim();
|
|
834
990
|
}
|
|
835
|
-
result[key.trim()] = parsedValue;
|
|
836
991
|
}
|
|
992
|
+
if (parsedValue.startsWith('"') && parsedValue.endsWith('"') || parsedValue.startsWith("'") && parsedValue.endsWith("'")) {
|
|
993
|
+
parsedValue = parsedValue.slice(1, -1);
|
|
994
|
+
}
|
|
995
|
+
result[key.trim()] = parsedValue;
|
|
837
996
|
}
|
|
838
997
|
return result;
|
|
839
998
|
}
|
|
840
999
|
function expandPath(path) {
|
|
841
1000
|
return path.replace(/^~/, homedir());
|
|
842
1001
|
}
|
|
843
|
-
var loggingEnabled = true;
|
|
844
|
-
function logToFile(message) {
|
|
845
|
-
if (!loggingEnabled)
|
|
846
|
-
return;
|
|
847
|
-
try {
|
|
848
|
-
const timestamp = new Date().toISOString();
|
|
849
|
-
Bun.appendFileSync(LOG_FILE, `[${timestamp}] ${message}
|
|
850
|
-
`);
|
|
851
|
-
} catch (e) {}
|
|
852
|
-
}
|
|
853
1002
|
async function loadConfig() {
|
|
1003
|
+
const timer = globalProfiler.startTimer("config.load");
|
|
854
1004
|
const configPaths = [
|
|
855
|
-
`${
|
|
856
|
-
`${
|
|
1005
|
+
`${process.cwd()}/${CONFIG_NAME}`,
|
|
1006
|
+
`${homedir()}/.config/opencode/${CONFIG_NAME}`
|
|
857
1007
|
];
|
|
858
1008
|
for (const configPath of configPaths) {
|
|
859
1009
|
try {
|
|
860
1010
|
const file = Bun.file(configPath);
|
|
861
|
-
if (!await file.exists())
|
|
862
|
-
continue;
|
|
863
1011
|
const content = await file.text();
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1012
|
+
if (!content || typeof content !== "string") {
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const config = parse2(content, [], {
|
|
1016
|
+
allowTrailingComma: true
|
|
1017
|
+
});
|
|
1018
|
+
if (config && typeof config === "object" && Array.isArray(config.files)) {
|
|
1019
|
+
loggingEnabled = config.logging?.enabled === true;
|
|
1020
|
+
const duration = timer();
|
|
1021
|
+
globalProfiler.recordConfigLoad(duration, configPath);
|
|
1022
|
+
return { config, path: configPath };
|
|
1023
|
+
}
|
|
1024
|
+
} catch {}
|
|
870
1025
|
}
|
|
871
|
-
|
|
1026
|
+
timer();
|
|
1027
|
+
return { config: { files: [], load_cwd_env: true }, path: null };
|
|
872
1028
|
}
|
|
873
1029
|
async function loadDotenvFile(filePath) {
|
|
1030
|
+
const start = performance.now();
|
|
874
1031
|
try {
|
|
875
1032
|
const file = Bun.file(filePath);
|
|
876
|
-
if (!await file.exists()) {
|
|
877
|
-
logToFile(`File not found: ${filePath}`);
|
|
878
|
-
return { count: 0, success: false };
|
|
879
|
-
}
|
|
880
1033
|
const content = await file.text();
|
|
881
|
-
if (typeof content !== "string") {
|
|
882
|
-
|
|
1034
|
+
if (typeof content !== "string" || !content) {
|
|
1035
|
+
const duration2 = performance.now() - start;
|
|
1036
|
+
globalProfiler.recordFileLoad(filePath, duration2, 0, false, "Empty or invalid content");
|
|
1037
|
+
log(`Invalid or empty content from ${filePath}`);
|
|
883
1038
|
return { count: 0, success: false };
|
|
884
1039
|
}
|
|
885
1040
|
const envVars = parseDotenv(content);
|
|
1041
|
+
const count = Object.keys(envVars).length;
|
|
886
1042
|
for (const [key, value] of Object.entries(envVars)) {
|
|
887
1043
|
process.env[key] = value;
|
|
888
1044
|
}
|
|
889
|
-
|
|
1045
|
+
const duration = performance.now() - start;
|
|
1046
|
+
globalProfiler.recordFileLoad(filePath, duration, count, true);
|
|
1047
|
+
return { count, success: true };
|
|
890
1048
|
} catch (error) {
|
|
891
|
-
|
|
1049
|
+
const duration = performance.now() - start;
|
|
1050
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1051
|
+
globalProfiler.recordFileLoad(filePath, duration, 0, false, errorMsg);
|
|
1052
|
+
log(`Failed to load ${filePath}: ${errorMsg}`);
|
|
892
1053
|
return { count: 0, success: false };
|
|
893
1054
|
}
|
|
894
1055
|
}
|
|
895
|
-
var DotEnvPlugin = async (
|
|
1056
|
+
var DotEnvPlugin = async (_ctx) => {
|
|
896
1057
|
if (globalThis[LOAD_GUARD]) {
|
|
897
1058
|
return {};
|
|
898
1059
|
}
|
|
899
1060
|
globalThis[LOAD_GUARD] = true;
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1061
|
+
globalProfiler.initStart();
|
|
1062
|
+
log("Plugin started");
|
|
1063
|
+
const { config, path: configPath } = await loadConfig();
|
|
1064
|
+
log(`Config loaded from ${configPath || "default"}: ${config.files.length} files, load_cwd_env=${config.load_cwd_env}, logging=${loggingEnabled}`);
|
|
1065
|
+
const filesToLoad = [...config.files.map(expandPath)];
|
|
1066
|
+
if (config.load_cwd_env !== false) {
|
|
1067
|
+
filesToLoad.push(`${process.cwd()}/.env`);
|
|
1068
|
+
}
|
|
903
1069
|
let totalFiles = 0;
|
|
904
1070
|
let totalVars = 0;
|
|
905
|
-
for (const
|
|
906
|
-
|
|
907
|
-
logToFile(`Loading: ${filePath}`);
|
|
1071
|
+
for (const filePath of filesToLoad) {
|
|
1072
|
+
log(`Loading: ${filePath}`);
|
|
908
1073
|
const result = await loadDotenvFile(filePath);
|
|
909
1074
|
if (result.success) {
|
|
910
1075
|
totalFiles++;
|
|
911
1076
|
totalVars += result.count;
|
|
912
|
-
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
if (config.load_cwd_env !== false) {
|
|
916
|
-
const cwdEnvPath = `${process.cwd()}/.env`;
|
|
917
|
-
logToFile(`Loading cwd: ${cwdEnvPath}`);
|
|
918
|
-
const result = await loadDotenvFile(cwdEnvPath);
|
|
919
|
-
if (result.success) {
|
|
920
|
-
totalFiles++;
|
|
921
|
-
totalVars += result.count;
|
|
922
|
-
logToFile(`Loaded ${result.count} vars from cwd`);
|
|
1077
|
+
log(`Loaded ${result.count} vars from ${filePath}`);
|
|
923
1078
|
}
|
|
924
1079
|
}
|
|
925
|
-
|
|
1080
|
+
globalProfiler.initComplete("ready");
|
|
1081
|
+
const initDuration = globalProfiler.getInitDuration();
|
|
1082
|
+
log(`Plugin finished: ${totalFiles} files, ${totalVars} vars in ${initDuration?.toFixed(2)}ms`);
|
|
926
1083
|
return {};
|
|
927
1084
|
};
|
|
928
|
-
var src_default = DotEnvPlugin;
|
|
929
1085
|
export {
|
|
930
|
-
|
|
931
|
-
src_default as default,
|
|
1086
|
+
DotEnvPlugin as default,
|
|
932
1087
|
DotEnvPlugin
|
|
933
1088
|
};
|
package/package.json
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-dotenv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "OpenCode plugin to load .env files at startup",
|
|
5
|
-
"main": "
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
6
7
|
"type": "module",
|
|
7
|
-
"
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
8
14
|
"scripts": {
|
|
9
15
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
16
|
+
"dev": "bun --hot src/index.ts",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"test:coverage": "bun test --coverage",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"bench": "bun run bench/init.bench.ts",
|
|
10
21
|
"prepublishOnly": "bun run build"
|
|
11
22
|
},
|
|
12
23
|
"files": [
|
|
13
|
-
"dist
|
|
24
|
+
"dist",
|
|
14
25
|
"README.md",
|
|
15
26
|
"LICENSE"
|
|
16
27
|
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"opencode",
|
|
30
|
+
"plugin",
|
|
31
|
+
"dotenv",
|
|
32
|
+
"env",
|
|
33
|
+
"environment"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
},
|
|
17
40
|
"peerDependencies": {
|
|
18
41
|
"@opencode-ai/plugin": "*"
|
|
19
42
|
},
|
|
20
43
|
"dependencies": {
|
|
21
44
|
"jsonc-parser": "^3.3.1"
|
|
22
|
-
}
|
|
23
|
-
"devDependencies": {}
|
|
45
|
+
}
|
|
24
46
|
}
|