scenv 0.5.1 → 0.6.1

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/dist/index.cjs CHANGED
@@ -86,13 +86,14 @@ async function defaultAskContext(name, contextNames) {
86
86
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
87
87
  var CONFIG_FILENAME = "scenv.config.json";
88
88
  var envKeyMap = {
89
- SCENV_CONTEXT: "contexts",
90
- SCENV_ADD_CONTEXTS: "addContexts",
89
+ SCENV_CONTEXT: "context",
90
+ SCENV_ADD_CONTEXT: "addContext",
91
91
  SCENV_PROMPT: "prompt",
92
92
  SCENV_IGNORE_ENV: "ignoreEnv",
93
93
  SCENV_IGNORE_CONTEXT: "ignoreContext",
94
94
  SCENV_SAVE_PROMPT: "shouldSavePrompt",
95
95
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
96
+ SCENV_CONTEXT_DIR: "contextDir",
96
97
  SCENV_LOG_LEVEL: "logLevel"
97
98
  };
98
99
  var programmaticConfig = {};
@@ -120,7 +121,7 @@ function configFromEnv() {
120
121
  for (const [envKey, configKey] of Object.entries(envKeyMap)) {
121
122
  const val = process.env[envKey];
122
123
  if (val === void 0 || val === "") continue;
123
- if (configKey === "contexts" || configKey === "addContexts") {
124
+ if (configKey === "context" || configKey === "addContext") {
124
125
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
125
126
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
126
127
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
@@ -132,6 +133,8 @@ function configFromEnv() {
132
133
  out[configKey] = v;
133
134
  } else if (configKey === "saveContextTo") {
134
135
  out.saveContextTo = val;
136
+ } else if (configKey === "contextDir") {
137
+ out.contextDir = val;
135
138
  } else if (configKey === "logLevel") {
136
139
  const v = val.toLowerCase();
137
140
  if (LOG_LEVELS.includes(v)) out.logLevel = v;
@@ -146,12 +149,20 @@ function loadConfigFile(configDir) {
146
149
  const raw = (0, import_node_fs.readFileSync)(path, "utf-8");
147
150
  const parsed = JSON.parse(raw);
148
151
  const out = {};
149
- if (Array.isArray(parsed.contexts))
150
- out.contexts = parsed.contexts.filter(
152
+ if (Array.isArray(parsed.context))
153
+ out.context = parsed.context.filter(
151
154
  (x) => typeof x === "string"
152
155
  );
153
- if (Array.isArray(parsed.addContexts))
154
- out.addContexts = parsed.addContexts.filter(
156
+ else if (Array.isArray(parsed.contexts))
157
+ out.context = parsed.contexts.filter(
158
+ (x) => typeof x === "string"
159
+ );
160
+ if (Array.isArray(parsed.addContext))
161
+ out.addContext = parsed.addContext.filter(
162
+ (x) => typeof x === "string"
163
+ );
164
+ else if (Array.isArray(parsed.addContexts))
165
+ out.addContext = parsed.addContexts.filter(
155
166
  (x) => typeof x === "string"
156
167
  );
157
168
  if (typeof parsed.prompt === "string" && ["always", "never", "fallback", "no-env"].includes(parsed.prompt))
@@ -165,6 +176,8 @@ function loadConfigFile(configDir) {
165
176
  out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
166
177
  if (typeof parsed.saveContextTo === "string")
167
178
  out.saveContextTo = parsed.saveContextTo;
179
+ if (typeof parsed.contextDir === "string") out.contextDir = parsed.contextDir;
180
+ else if (typeof parsed.contextsDir === "string") out.contextDir = parsed.contextsDir;
168
181
  if (typeof parsed.root === "string") out.root = parsed.root;
169
182
  if (typeof parsed.logLevel === "string" && LOG_LEVELS.includes(parsed.logLevel))
170
183
  out.logLevel = parsed.logLevel;
@@ -180,11 +193,11 @@ function loadConfigFile(configDir) {
180
193
  }
181
194
  }
182
195
  function mergeContexts(fileConfig, envConfig, progConfig) {
183
- const fromFile = fileConfig.contexts ?? fileConfig.addContexts ?? [];
184
- const fromEnvAdd = envConfig.addContexts ?? [];
185
- const fromEnvReplace = envConfig.contexts;
186
- const fromProgAdd = progConfig.addContexts ?? [];
187
- const fromProgReplace = progConfig.contexts;
196
+ const fromFile = fileConfig.context ?? fileConfig.addContext ?? [];
197
+ const fromEnvAdd = envConfig.addContext ?? [];
198
+ const fromEnvReplace = envConfig.context;
199
+ const fromProgAdd = progConfig.addContext ?? [];
200
+ const fromProgReplace = progConfig.context;
188
201
  const replace = fromProgReplace ?? fromEnvReplace;
189
202
  if (replace !== void 0) return replace;
190
203
  const base = [...fromFile];
@@ -208,8 +221,8 @@ function loadConfig(root) {
208
221
  ...envConfig,
209
222
  ...programmaticConfig
210
223
  };
211
- merged.contexts = mergeContexts(fileConfig, envConfig, programmaticConfig);
212
- delete merged.addContexts;
224
+ merged.context = mergeContexts(fileConfig, envConfig, programmaticConfig);
225
+ delete merged.addContext;
213
226
  if (configDir && !merged.root) merged.root = configDir;
214
227
  else if (!merged.root) merged.root = startDir;
215
228
  return merged;
@@ -244,16 +257,43 @@ var configLoadedLogged = false;
244
257
  function resetLogState() {
245
258
  configLoadedLogged = false;
246
259
  }
260
+ var CONFIG_LOG_KEYS = [
261
+ "root",
262
+ "context",
263
+ "prompt",
264
+ "ignoreEnv",
265
+ "ignoreContext",
266
+ "set",
267
+ "shouldSavePrompt",
268
+ "saveContextTo",
269
+ "contextDir",
270
+ "logLevel"
271
+ ];
272
+ function configForLog(config) {
273
+ return Object.fromEntries(
274
+ CONFIG_LOG_KEYS.filter((k) => config[k] !== void 0).map((k) => [k, config[k]])
275
+ );
276
+ }
247
277
  function logConfigLoaded(config) {
248
278
  if (configLoadedLogged) return;
249
- if (getLevelNum() < LEVEL_NUM.info) return;
250
279
  configLoadedLogged = true;
251
- log(
252
- "info",
253
- "config loaded",
254
- "root=" + (config.root ?? "(cwd)"),
255
- "contexts=" + JSON.stringify(config.contexts ?? [])
256
- );
280
+ const levelNum = LEVEL_NUM[config.logLevel ?? "none"];
281
+ if (levelNum >= LEVEL_NUM.info) {
282
+ const parts = [
283
+ "root=" + (config.root ?? "(cwd)"),
284
+ "context=" + JSON.stringify(config.context ?? [])
285
+ ];
286
+ if (config.prompt !== void 0) parts.push("prompt=" + config.prompt);
287
+ if (config.ignoreEnv === true) parts.push("ignoreEnv=true");
288
+ if (config.ignoreContext === true) parts.push("ignoreContext=true");
289
+ if (config.saveContextTo !== void 0) parts.push("saveContextTo=" + config.saveContextTo);
290
+ if (config.contextDir !== void 0) parts.push("contextDir=" + config.contextDir);
291
+ if (config.shouldSavePrompt !== void 0) parts.push("shouldSavePrompt=" + config.shouldSavePrompt);
292
+ log("info", "config loaded", parts.join(" "));
293
+ }
294
+ if (levelNum >= LEVEL_NUM.debug) {
295
+ log("debug", "config (full)", JSON.stringify(configForLog(config)));
296
+ }
257
297
  }
258
298
  function log(level, msg, ...args) {
259
299
  const configured = getLevelNum();
@@ -338,7 +378,7 @@ function getMergedContextValues() {
338
378
  const root = config.root ?? process.cwd();
339
379
  const paths = discoverContextPaths(root);
340
380
  const out = {};
341
- for (const contextName of config.contexts ?? []) {
381
+ for (const contextName of config.context ?? []) {
342
382
  const filePath = paths.get(contextName);
343
383
  if (!filePath) {
344
384
  log("warn", `context "${contextName}" not found (no *.context.json)`);
@@ -370,7 +410,8 @@ function getContextWritePath(contextName) {
370
410
  const paths = discoverContextPaths(root);
371
411
  const existing = paths.get(contextName);
372
412
  if (existing) return existing;
373
- return (0, import_node_path2.join)(root, `${contextName}${CONTEXT_SUFFIX}`);
413
+ const saveDir = config.contextDir ? (0, import_node_path2.isAbsolute)(config.contextDir) ? config.contextDir : (0, import_node_path2.join)(root, config.contextDir) : root;
414
+ return (0, import_node_path2.join)(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
374
415
  }
375
416
  function writeToContext(contextName, key, value) {
376
417
  const path = getContextWritePath(contextName);
@@ -388,26 +429,49 @@ function writeToContext(contextName, key, value) {
388
429
  }
389
430
 
390
431
  // src/variable.ts
391
- var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
432
+ var CONTEXT_REF_FULL_REGEX = /^@([^:]+):(.+)$/;
433
+ var CONTEXT_REF_SHORT_REGEX = /^@([^:]+)$/;
434
+ var ENV_REF_REGEX = /\$([A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/g;
392
435
  var MAX_CONTEXT_REF_DEPTH = 10;
393
- function resolveContextReference(raw, depth = 0) {
436
+ function expandEnvReferences(str) {
437
+ return str.replace(ENV_REF_REGEX, (_, name) => {
438
+ const key = name.startsWith("{") ? name.slice(1, -1) : name;
439
+ return process.env[key] ?? "";
440
+ });
441
+ }
442
+ function resolveContextReference(raw, currentKey, depth = 0) {
394
443
  if (depth >= MAX_CONTEXT_REF_DEPTH) {
395
444
  throw new Error(
396
445
  `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
397
446
  );
398
447
  }
399
- const match = raw.match(CONTEXT_REF_REGEX);
400
- if (!match) return raw;
401
- const [, contextName, refKey] = match;
448
+ const afterEnv = expandEnvReferences(raw);
449
+ let contextName;
450
+ let refKey;
451
+ const fullMatch = afterEnv.match(CONTEXT_REF_FULL_REGEX);
452
+ if (fullMatch) {
453
+ [, contextName, refKey] = fullMatch;
454
+ } else {
455
+ const shortMatch = afterEnv.match(CONTEXT_REF_SHORT_REGEX);
456
+ if (!shortMatch) return afterEnv;
457
+ contextName = shortMatch[1];
458
+ if (currentKey === void 0) {
459
+ throw new Error(
460
+ `Context reference @${contextName} (short form) cannot be resolved without a variable key. Use @${contextName}:<key> to specify a key.`
461
+ );
462
+ }
463
+ refKey = currentKey;
464
+ }
402
465
  const ctx = getContext(contextName);
403
466
  const resolved = ctx[refKey];
404
467
  if (resolved === void 0) {
405
468
  const hasContext = Object.keys(ctx).length > 0;
406
- const msg = hasContext ? `Context reference @${contextName}:${refKey} could not be resolved: key "${refKey}" is not defined in context "${contextName}".` : `Context reference @${contextName}:${refKey} could not be resolved: context "${contextName}" not found (no ${contextName}.context.json).`;
469
+ const displayRef = refKey === currentKey ? `@${contextName}` : `@${contextName}:${refKey}`;
470
+ const msg = hasContext ? `Context reference ${displayRef} could not be resolved: key "${refKey}" is not defined in context "${contextName}".` : `Context reference ${displayRef} could not be resolved: context "${contextName}" not found (no ${contextName}.context.json).`;
407
471
  log("error", msg);
408
472
  throw new Error(msg);
409
473
  }
410
- return resolveContextReference(resolved, depth + 1);
474
+ return resolveContextReference(resolved, currentKey, depth + 1);
411
475
  }
412
476
  function defaultKeyFromName(name) {
413
477
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
@@ -433,7 +497,7 @@ function scenv(name, options = {}) {
433
497
  log("trace", `resolveRaw: checking set for key=${key}`);
434
498
  if (config.set?.[key] !== void 0) {
435
499
  log("trace", `resolveRaw: set hit key=${key}`);
436
- const raw = resolveContextReference(config.set[key]);
500
+ const raw = resolveContextReference(config.set[key], key);
437
501
  return { raw, source: "set" };
438
502
  }
439
503
  if (!config.ignoreEnv) {
@@ -441,7 +505,7 @@ function scenv(name, options = {}) {
441
505
  const envVal = process.env[envKey];
442
506
  if (envVal !== void 0 && envVal !== "") {
443
507
  log("trace", "resolveRaw: env hit");
444
- const raw = resolveContextReference(envVal);
508
+ const raw = resolveContextReference(envVal, key);
445
509
  return { raw, source: "env" };
446
510
  }
447
511
  }
@@ -450,7 +514,7 @@ function scenv(name, options = {}) {
450
514
  const ctx = getMergedContextValues();
451
515
  if (ctx[key] !== void 0) {
452
516
  log("trace", `resolveRaw: context hit key=${key}`);
453
- const raw = resolveContextReference(ctx[key]);
517
+ const raw = resolveContextReference(ctx[key], key);
454
518
  return { raw, source: "context" };
455
519
  }
456
520
  }
@@ -477,7 +541,7 @@ function scenv(name, options = {}) {
477
541
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
478
542
  );
479
543
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
480
- const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
544
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault, key) : effectiveDefault;
481
545
  let wasPrompted = false;
482
546
  let value;
483
547
  let resolvedFrom;
@@ -491,7 +555,7 @@ function scenv(name, options = {}) {
491
555
  }
492
556
  const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
493
557
  let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
494
- value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
558
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue, key) : promptedValue;
495
559
  wasPrompted = true;
496
560
  resolvedFrom = "prompt";
497
561
  } else if (raw !== void 0) {
@@ -546,7 +610,7 @@ function scenv(name, options = {}) {
546
610
  }
547
611
  if (!doSave) return final;
548
612
  const callbacks = getCallbacks();
549
- const contextNames = config.contexts ?? [];
613
+ const contextNames = config.context ?? [];
550
614
  let ctxToSave;
551
615
  if (config.saveContextTo === "ask") {
552
616
  if (typeof callbacks.onAskContext !== "function") {
@@ -590,10 +654,10 @@ function scenv(name, options = {}) {
590
654
  }
591
655
  contextName = await callbacks.onAskContext(
592
656
  name,
593
- config.contexts ?? []
657
+ config.context ?? []
594
658
  );
595
659
  }
596
- if (!contextName) contextName = config.contexts?.[0] ?? "default";
660
+ if (!contextName) contextName = config.context?.[0] ?? "default";
597
661
  writeToContext(contextName, key, String(validated.data));
598
662
  log("info", `Saved key=${key} to context ${contextName}`);
599
663
  }
@@ -607,9 +671,9 @@ function parseScenvArgs(argv) {
607
671
  while (i < argv.length) {
608
672
  const arg = argv[i];
609
673
  if (arg === "--context" && argv[i + 1] !== void 0) {
610
- config.contexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
674
+ config.context = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
611
675
  } else if (arg === "--add-context" && argv[i + 1] !== void 0) {
612
- config.addContexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
676
+ config.addContext = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
613
677
  } else if (arg === "--prompt" && argv[i + 1] !== void 0) {
614
678
  const v = argv[++i].toLowerCase();
615
679
  if (["always", "never", "fallback", "no-env"].includes(v)) {
@@ -633,6 +697,8 @@ function parseScenvArgs(argv) {
633
697
  }
634
698
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
635
699
  config.saveContextTo = argv[++i];
700
+ } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
701
+ config.contextDir = argv[++i];
636
702
  } else if ((arg === "--log-level" || arg === "--log") && argv[i + 1] !== void 0) {
637
703
  const v = argv[++i].toLowerCase();
638
704
  if (LOG_LEVELS.includes(v)) {
package/dist/index.d.cts CHANGED
@@ -27,9 +27,9 @@ type LogLevel = (typeof LOG_LEVELS)[number];
27
27
  */
28
28
  interface ScenvConfig {
29
29
  /** Replace the context list entirely (CLI: `--context a,b,c`). Loaded in order; later overwrites earlier for same key. */
30
- contexts?: string[];
31
- /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `contexts` is set in the same layer. */
32
- addContexts?: string[];
30
+ context?: string[];
31
+ /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `context` is set in the same layer. */
32
+ addContext?: string[];
33
33
  /** When to prompt for a variable value. See {@link PromptMode}. Default is `"fallback"`. */
34
34
  prompt?: PromptMode;
35
35
  /** If true, environment variables are not used during resolution. */
@@ -42,6 +42,8 @@ interface ScenvConfig {
42
42
  shouldSavePrompt?: SavePromptMode;
43
43
  /** Target context for saving: a context name, or `"ask"` to use {@link ScenvCallbacks.onAskContext}. */
44
44
  saveContextTo?: "ask" | string;
45
+ /** Directory to save context files to when the context is not already discovered. Relative to root unless absolute. If unset, new context files are saved under root. */
46
+ contextDir?: string;
45
47
  /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
46
48
  root?: string;
47
49
  /** Logging level. Default is `"none"`. Messages go to stderr. */
@@ -79,7 +81,7 @@ declare function getCallbacks(): ScenvCallbacks;
79
81
  * Use this to read the effective config (e.g. for logging or conditional logic).
80
82
  *
81
83
  * @param root - Optional directory to start searching for scenv.config.json. If omitted, uses programmatic config.root or process.cwd().
82
- * @returns The merged {@link ScenvConfig} with at least `root` and `contexts` defined.
84
+ * @returns The merged {@link ScenvConfig} with at least `root` and `context` defined.
83
85
  */
84
86
  declare function loadConfig(root?: string): ScenvConfig;
85
87
  /**
@@ -119,7 +121,7 @@ declare function resetLogState(): void;
119
121
  declare function discoverContextPaths(dir: string, found?: Map<string, string>): Map<string, string>;
120
122
  /**
121
123
  * Loads key-value pairs from a single context file. Used when resolving @context:key references.
122
- * Does not depend on config.contexts or ignoreContext; the context file is read if it exists
124
+ * Does not depend on config.context or ignoreContext; the context file is read if it exists
123
125
  * under the config root.
124
126
  *
125
127
  * @param contextName - Name of the context (e.g. "prod", "dev") — file is contextName.context.json.
@@ -128,12 +130,12 @@ declare function discoverContextPaths(dir: string, found?: Map<string, string>):
128
130
  */
129
131
  declare function getContext(contextName: string, root?: string): Record<string, string>;
130
132
  /**
131
- * Loads and merges context values from the current config. Respects {@link ScenvConfig.contexts}
133
+ * Loads and merges context values from the current config. Respects {@link ScenvConfig.context}
132
134
  * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
133
135
  * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
134
136
  * resolution (set > env > context > default).
135
137
  *
136
- * @returns A flat record of key → string value. Empty if ignoreContext is true or no contexts loaded.
138
+ * @returns A flat record of key → string value. Empty if ignoreContext is true or no context loaded.
137
139
  */
138
140
  declare function getMergedContextValues(): Record<string, string>;
139
141
 
@@ -218,7 +220,7 @@ interface ScenvVariable<T> {
218
220
  * get() first looks for a raw value in this order, stopping at the first found:
219
221
  * - Set overrides from config (e.g. from --set key=value or configure({ set: { key: "value" } }))
220
222
  * - Environment variable (e.g. API_URL for key "api_url")
221
- * - Context files (merged key-value from the contexts in config, e.g. dev.context.json)
223
+ * - Context files (merged key-value from the context list in config, e.g. dev.context.json)
222
224
  *
223
225
  * If config says to prompt (see prompt mode in config: "always", "fallback", "no-env"), the prompt callback
224
226
  * may run. When it runs, it receives the variable name and a suggested value (the raw value if any, otherwise
@@ -254,14 +256,15 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
254
256
  * Typical use: `configure(parseScenvArgs(process.argv.slice(2)))`. Unrecognized flags are ignored.
255
257
  *
256
258
  * Supported flags:
257
- * - `--context a,b,c` – Set contexts (replace).
258
- * - `--add-context x,y` – Add contexts.
259
+ * - `--context a,b,c` – Set context list (replace).
260
+ * - `--add-context x,y` – Add context names.
259
261
  * - `--prompt always|never|fallback|no-env` – Prompt mode.
260
262
  * - `--ignore-env` – Set ignoreEnv to true.
261
263
  * - `--ignore-context` – Set ignoreContext to true.
262
264
  * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
263
265
  * - `--save-prompt always|never|ask` – shouldSavePrompt.
264
266
  * - `--save-context-to name` – saveContextTo.
267
+ * - `--context-dir path` – contextDir (directory to save context files to by default).
265
268
  * - `--log-level level`, `--log level`, `--log=level` – logLevel.
266
269
  *
267
270
  * @param argv - Array of CLI arguments (e.g. process.argv.slice(2)).
package/dist/index.d.ts CHANGED
@@ -27,9 +27,9 @@ type LogLevel = (typeof LOG_LEVELS)[number];
27
27
  */
28
28
  interface ScenvConfig {
29
29
  /** Replace the context list entirely (CLI: `--context a,b,c`). Loaded in order; later overwrites earlier for same key. */
30
- contexts?: string[];
31
- /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `contexts` is set in the same layer. */
32
- addContexts?: string[];
30
+ context?: string[];
31
+ /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `context` is set in the same layer. */
32
+ addContext?: string[];
33
33
  /** When to prompt for a variable value. See {@link PromptMode}. Default is `"fallback"`. */
34
34
  prompt?: PromptMode;
35
35
  /** If true, environment variables are not used during resolution. */
@@ -42,6 +42,8 @@ interface ScenvConfig {
42
42
  shouldSavePrompt?: SavePromptMode;
43
43
  /** Target context for saving: a context name, or `"ask"` to use {@link ScenvCallbacks.onAskContext}. */
44
44
  saveContextTo?: "ask" | string;
45
+ /** Directory to save context files to when the context is not already discovered. Relative to root unless absolute. If unset, new context files are saved under root. */
46
+ contextDir?: string;
45
47
  /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
46
48
  root?: string;
47
49
  /** Logging level. Default is `"none"`. Messages go to stderr. */
@@ -79,7 +81,7 @@ declare function getCallbacks(): ScenvCallbacks;
79
81
  * Use this to read the effective config (e.g. for logging or conditional logic).
80
82
  *
81
83
  * @param root - Optional directory to start searching for scenv.config.json. If omitted, uses programmatic config.root or process.cwd().
82
- * @returns The merged {@link ScenvConfig} with at least `root` and `contexts` defined.
84
+ * @returns The merged {@link ScenvConfig} with at least `root` and `context` defined.
83
85
  */
84
86
  declare function loadConfig(root?: string): ScenvConfig;
85
87
  /**
@@ -119,7 +121,7 @@ declare function resetLogState(): void;
119
121
  declare function discoverContextPaths(dir: string, found?: Map<string, string>): Map<string, string>;
120
122
  /**
121
123
  * Loads key-value pairs from a single context file. Used when resolving @context:key references.
122
- * Does not depend on config.contexts or ignoreContext; the context file is read if it exists
124
+ * Does not depend on config.context or ignoreContext; the context file is read if it exists
123
125
  * under the config root.
124
126
  *
125
127
  * @param contextName - Name of the context (e.g. "prod", "dev") — file is contextName.context.json.
@@ -128,12 +130,12 @@ declare function discoverContextPaths(dir: string, found?: Map<string, string>):
128
130
  */
129
131
  declare function getContext(contextName: string, root?: string): Record<string, string>;
130
132
  /**
131
- * Loads and merges context values from the current config. Respects {@link ScenvConfig.contexts}
133
+ * Loads and merges context values from the current config. Respects {@link ScenvConfig.context}
132
134
  * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
133
135
  * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
134
136
  * resolution (set > env > context > default).
135
137
  *
136
- * @returns A flat record of key → string value. Empty if ignoreContext is true or no contexts loaded.
138
+ * @returns A flat record of key → string value. Empty if ignoreContext is true or no context loaded.
137
139
  */
138
140
  declare function getMergedContextValues(): Record<string, string>;
139
141
 
@@ -218,7 +220,7 @@ interface ScenvVariable<T> {
218
220
  * get() first looks for a raw value in this order, stopping at the first found:
219
221
  * - Set overrides from config (e.g. from --set key=value or configure({ set: { key: "value" } }))
220
222
  * - Environment variable (e.g. API_URL for key "api_url")
221
- * - Context files (merged key-value from the contexts in config, e.g. dev.context.json)
223
+ * - Context files (merged key-value from the context list in config, e.g. dev.context.json)
222
224
  *
223
225
  * If config says to prompt (see prompt mode in config: "always", "fallback", "no-env"), the prompt callback
224
226
  * may run. When it runs, it receives the variable name and a suggested value (the raw value if any, otherwise
@@ -254,14 +256,15 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
254
256
  * Typical use: `configure(parseScenvArgs(process.argv.slice(2)))`. Unrecognized flags are ignored.
255
257
  *
256
258
  * Supported flags:
257
- * - `--context a,b,c` – Set contexts (replace).
258
- * - `--add-context x,y` – Add contexts.
259
+ * - `--context a,b,c` – Set context list (replace).
260
+ * - `--add-context x,y` – Add context names.
259
261
  * - `--prompt always|never|fallback|no-env` – Prompt mode.
260
262
  * - `--ignore-env` – Set ignoreEnv to true.
261
263
  * - `--ignore-context` – Set ignoreContext to true.
262
264
  * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
263
265
  * - `--save-prompt always|never|ask` – shouldSavePrompt.
264
266
  * - `--save-context-to name` – saveContextTo.
267
+ * - `--context-dir path` – contextDir (directory to save context files to by default).
265
268
  * - `--log-level level`, `--log level`, `--log=level` – logLevel.
266
269
  *
267
270
  * @param argv - Array of CLI arguments (e.g. process.argv.slice(2)).
package/dist/index.js CHANGED
@@ -50,13 +50,14 @@ async function defaultAskContext(name, contextNames) {
50
50
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
51
51
  var CONFIG_FILENAME = "scenv.config.json";
52
52
  var envKeyMap = {
53
- SCENV_CONTEXT: "contexts",
54
- SCENV_ADD_CONTEXTS: "addContexts",
53
+ SCENV_CONTEXT: "context",
54
+ SCENV_ADD_CONTEXT: "addContext",
55
55
  SCENV_PROMPT: "prompt",
56
56
  SCENV_IGNORE_ENV: "ignoreEnv",
57
57
  SCENV_IGNORE_CONTEXT: "ignoreContext",
58
58
  SCENV_SAVE_PROMPT: "shouldSavePrompt",
59
59
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
60
+ SCENV_CONTEXT_DIR: "contextDir",
60
61
  SCENV_LOG_LEVEL: "logLevel"
61
62
  };
62
63
  var programmaticConfig = {};
@@ -84,7 +85,7 @@ function configFromEnv() {
84
85
  for (const [envKey, configKey] of Object.entries(envKeyMap)) {
85
86
  const val = process.env[envKey];
86
87
  if (val === void 0 || val === "") continue;
87
- if (configKey === "contexts" || configKey === "addContexts") {
88
+ if (configKey === "context" || configKey === "addContext") {
88
89
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
89
90
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
90
91
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
@@ -96,6 +97,8 @@ function configFromEnv() {
96
97
  out[configKey] = v;
97
98
  } else if (configKey === "saveContextTo") {
98
99
  out.saveContextTo = val;
100
+ } else if (configKey === "contextDir") {
101
+ out.contextDir = val;
99
102
  } else if (configKey === "logLevel") {
100
103
  const v = val.toLowerCase();
101
104
  if (LOG_LEVELS.includes(v)) out.logLevel = v;
@@ -110,12 +113,20 @@ function loadConfigFile(configDir) {
110
113
  const raw = readFileSync(path, "utf-8");
111
114
  const parsed = JSON.parse(raw);
112
115
  const out = {};
113
- if (Array.isArray(parsed.contexts))
114
- out.contexts = parsed.contexts.filter(
116
+ if (Array.isArray(parsed.context))
117
+ out.context = parsed.context.filter(
115
118
  (x) => typeof x === "string"
116
119
  );
117
- if (Array.isArray(parsed.addContexts))
118
- out.addContexts = parsed.addContexts.filter(
120
+ else if (Array.isArray(parsed.contexts))
121
+ out.context = parsed.contexts.filter(
122
+ (x) => typeof x === "string"
123
+ );
124
+ if (Array.isArray(parsed.addContext))
125
+ out.addContext = parsed.addContext.filter(
126
+ (x) => typeof x === "string"
127
+ );
128
+ else if (Array.isArray(parsed.addContexts))
129
+ out.addContext = parsed.addContexts.filter(
119
130
  (x) => typeof x === "string"
120
131
  );
121
132
  if (typeof parsed.prompt === "string" && ["always", "never", "fallback", "no-env"].includes(parsed.prompt))
@@ -129,6 +140,8 @@ function loadConfigFile(configDir) {
129
140
  out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
130
141
  if (typeof parsed.saveContextTo === "string")
131
142
  out.saveContextTo = parsed.saveContextTo;
143
+ if (typeof parsed.contextDir === "string") out.contextDir = parsed.contextDir;
144
+ else if (typeof parsed.contextsDir === "string") out.contextDir = parsed.contextsDir;
132
145
  if (typeof parsed.root === "string") out.root = parsed.root;
133
146
  if (typeof parsed.logLevel === "string" && LOG_LEVELS.includes(parsed.logLevel))
134
147
  out.logLevel = parsed.logLevel;
@@ -144,11 +157,11 @@ function loadConfigFile(configDir) {
144
157
  }
145
158
  }
146
159
  function mergeContexts(fileConfig, envConfig, progConfig) {
147
- const fromFile = fileConfig.contexts ?? fileConfig.addContexts ?? [];
148
- const fromEnvAdd = envConfig.addContexts ?? [];
149
- const fromEnvReplace = envConfig.contexts;
150
- const fromProgAdd = progConfig.addContexts ?? [];
151
- const fromProgReplace = progConfig.contexts;
160
+ const fromFile = fileConfig.context ?? fileConfig.addContext ?? [];
161
+ const fromEnvAdd = envConfig.addContext ?? [];
162
+ const fromEnvReplace = envConfig.context;
163
+ const fromProgAdd = progConfig.addContext ?? [];
164
+ const fromProgReplace = progConfig.context;
152
165
  const replace = fromProgReplace ?? fromEnvReplace;
153
166
  if (replace !== void 0) return replace;
154
167
  const base = [...fromFile];
@@ -172,8 +185,8 @@ function loadConfig(root) {
172
185
  ...envConfig,
173
186
  ...programmaticConfig
174
187
  };
175
- merged.contexts = mergeContexts(fileConfig, envConfig, programmaticConfig);
176
- delete merged.addContexts;
188
+ merged.context = mergeContexts(fileConfig, envConfig, programmaticConfig);
189
+ delete merged.addContext;
177
190
  if (configDir && !merged.root) merged.root = configDir;
178
191
  else if (!merged.root) merged.root = startDir;
179
192
  return merged;
@@ -208,16 +221,43 @@ var configLoadedLogged = false;
208
221
  function resetLogState() {
209
222
  configLoadedLogged = false;
210
223
  }
224
+ var CONFIG_LOG_KEYS = [
225
+ "root",
226
+ "context",
227
+ "prompt",
228
+ "ignoreEnv",
229
+ "ignoreContext",
230
+ "set",
231
+ "shouldSavePrompt",
232
+ "saveContextTo",
233
+ "contextDir",
234
+ "logLevel"
235
+ ];
236
+ function configForLog(config) {
237
+ return Object.fromEntries(
238
+ CONFIG_LOG_KEYS.filter((k) => config[k] !== void 0).map((k) => [k, config[k]])
239
+ );
240
+ }
211
241
  function logConfigLoaded(config) {
212
242
  if (configLoadedLogged) return;
213
- if (getLevelNum() < LEVEL_NUM.info) return;
214
243
  configLoadedLogged = true;
215
- log(
216
- "info",
217
- "config loaded",
218
- "root=" + (config.root ?? "(cwd)"),
219
- "contexts=" + JSON.stringify(config.contexts ?? [])
220
- );
244
+ const levelNum = LEVEL_NUM[config.logLevel ?? "none"];
245
+ if (levelNum >= LEVEL_NUM.info) {
246
+ const parts = [
247
+ "root=" + (config.root ?? "(cwd)"),
248
+ "context=" + JSON.stringify(config.context ?? [])
249
+ ];
250
+ if (config.prompt !== void 0) parts.push("prompt=" + config.prompt);
251
+ if (config.ignoreEnv === true) parts.push("ignoreEnv=true");
252
+ if (config.ignoreContext === true) parts.push("ignoreContext=true");
253
+ if (config.saveContextTo !== void 0) parts.push("saveContextTo=" + config.saveContextTo);
254
+ if (config.contextDir !== void 0) parts.push("contextDir=" + config.contextDir);
255
+ if (config.shouldSavePrompt !== void 0) parts.push("shouldSavePrompt=" + config.shouldSavePrompt);
256
+ log("info", "config loaded", parts.join(" "));
257
+ }
258
+ if (levelNum >= LEVEL_NUM.debug) {
259
+ log("debug", "config (full)", JSON.stringify(configForLog(config)));
260
+ }
221
261
  }
222
262
  function log(level, msg, ...args) {
223
263
  const configured = getLevelNum();
@@ -239,7 +279,7 @@ import {
239
279
  readdirSync,
240
280
  statSync
241
281
  } from "fs";
242
- import { join as join2, dirname as dirname2 } from "path";
282
+ import { join as join2, dirname as dirname2, isAbsolute } from "path";
243
283
  var CONTEXT_SUFFIX = ".context.json";
244
284
  function discoverContextPathsInternal(dir, found) {
245
285
  let entries;
@@ -308,7 +348,7 @@ function getMergedContextValues() {
308
348
  const root = config.root ?? process.cwd();
309
349
  const paths = discoverContextPaths(root);
310
350
  const out = {};
311
- for (const contextName of config.contexts ?? []) {
351
+ for (const contextName of config.context ?? []) {
312
352
  const filePath = paths.get(contextName);
313
353
  if (!filePath) {
314
354
  log("warn", `context "${contextName}" not found (no *.context.json)`);
@@ -340,7 +380,8 @@ function getContextWritePath(contextName) {
340
380
  const paths = discoverContextPaths(root);
341
381
  const existing = paths.get(contextName);
342
382
  if (existing) return existing;
343
- return join2(root, `${contextName}${CONTEXT_SUFFIX}`);
383
+ const saveDir = config.contextDir ? isAbsolute(config.contextDir) ? config.contextDir : join2(root, config.contextDir) : root;
384
+ return join2(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
344
385
  }
345
386
  function writeToContext(contextName, key, value) {
346
387
  const path = getContextWritePath(contextName);
@@ -358,26 +399,49 @@ function writeToContext(contextName, key, value) {
358
399
  }
359
400
 
360
401
  // src/variable.ts
361
- var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
402
+ var CONTEXT_REF_FULL_REGEX = /^@([^:]+):(.+)$/;
403
+ var CONTEXT_REF_SHORT_REGEX = /^@([^:]+)$/;
404
+ var ENV_REF_REGEX = /\$([A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/g;
362
405
  var MAX_CONTEXT_REF_DEPTH = 10;
363
- function resolveContextReference(raw, depth = 0) {
406
+ function expandEnvReferences(str) {
407
+ return str.replace(ENV_REF_REGEX, (_, name) => {
408
+ const key = name.startsWith("{") ? name.slice(1, -1) : name;
409
+ return process.env[key] ?? "";
410
+ });
411
+ }
412
+ function resolveContextReference(raw, currentKey, depth = 0) {
364
413
  if (depth >= MAX_CONTEXT_REF_DEPTH) {
365
414
  throw new Error(
366
415
  `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
367
416
  );
368
417
  }
369
- const match = raw.match(CONTEXT_REF_REGEX);
370
- if (!match) return raw;
371
- const [, contextName, refKey] = match;
418
+ const afterEnv = expandEnvReferences(raw);
419
+ let contextName;
420
+ let refKey;
421
+ const fullMatch = afterEnv.match(CONTEXT_REF_FULL_REGEX);
422
+ if (fullMatch) {
423
+ [, contextName, refKey] = fullMatch;
424
+ } else {
425
+ const shortMatch = afterEnv.match(CONTEXT_REF_SHORT_REGEX);
426
+ if (!shortMatch) return afterEnv;
427
+ contextName = shortMatch[1];
428
+ if (currentKey === void 0) {
429
+ throw new Error(
430
+ `Context reference @${contextName} (short form) cannot be resolved without a variable key. Use @${contextName}:<key> to specify a key.`
431
+ );
432
+ }
433
+ refKey = currentKey;
434
+ }
372
435
  const ctx = getContext(contextName);
373
436
  const resolved = ctx[refKey];
374
437
  if (resolved === void 0) {
375
438
  const hasContext = Object.keys(ctx).length > 0;
376
- const msg = hasContext ? `Context reference @${contextName}:${refKey} could not be resolved: key "${refKey}" is not defined in context "${contextName}".` : `Context reference @${contextName}:${refKey} could not be resolved: context "${contextName}" not found (no ${contextName}.context.json).`;
439
+ const displayRef = refKey === currentKey ? `@${contextName}` : `@${contextName}:${refKey}`;
440
+ const msg = hasContext ? `Context reference ${displayRef} could not be resolved: key "${refKey}" is not defined in context "${contextName}".` : `Context reference ${displayRef} could not be resolved: context "${contextName}" not found (no ${contextName}.context.json).`;
377
441
  log("error", msg);
378
442
  throw new Error(msg);
379
443
  }
380
- return resolveContextReference(resolved, depth + 1);
444
+ return resolveContextReference(resolved, currentKey, depth + 1);
381
445
  }
382
446
  function defaultKeyFromName(name) {
383
447
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
@@ -403,7 +467,7 @@ function scenv(name, options = {}) {
403
467
  log("trace", `resolveRaw: checking set for key=${key}`);
404
468
  if (config.set?.[key] !== void 0) {
405
469
  log("trace", `resolveRaw: set hit key=${key}`);
406
- const raw = resolveContextReference(config.set[key]);
470
+ const raw = resolveContextReference(config.set[key], key);
407
471
  return { raw, source: "set" };
408
472
  }
409
473
  if (!config.ignoreEnv) {
@@ -411,7 +475,7 @@ function scenv(name, options = {}) {
411
475
  const envVal = process.env[envKey];
412
476
  if (envVal !== void 0 && envVal !== "") {
413
477
  log("trace", "resolveRaw: env hit");
414
- const raw = resolveContextReference(envVal);
478
+ const raw = resolveContextReference(envVal, key);
415
479
  return { raw, source: "env" };
416
480
  }
417
481
  }
@@ -420,7 +484,7 @@ function scenv(name, options = {}) {
420
484
  const ctx = getMergedContextValues();
421
485
  if (ctx[key] !== void 0) {
422
486
  log("trace", `resolveRaw: context hit key=${key}`);
423
- const raw = resolveContextReference(ctx[key]);
487
+ const raw = resolveContextReference(ctx[key], key);
424
488
  return { raw, source: "context" };
425
489
  }
426
490
  }
@@ -447,7 +511,7 @@ function scenv(name, options = {}) {
447
511
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
448
512
  );
449
513
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
450
- const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
514
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault, key) : effectiveDefault;
451
515
  let wasPrompted = false;
452
516
  let value;
453
517
  let resolvedFrom;
@@ -461,7 +525,7 @@ function scenv(name, options = {}) {
461
525
  }
462
526
  const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
463
527
  let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
464
- value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
528
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue, key) : promptedValue;
465
529
  wasPrompted = true;
466
530
  resolvedFrom = "prompt";
467
531
  } else if (raw !== void 0) {
@@ -516,7 +580,7 @@ function scenv(name, options = {}) {
516
580
  }
517
581
  if (!doSave) return final;
518
582
  const callbacks = getCallbacks();
519
- const contextNames = config.contexts ?? [];
583
+ const contextNames = config.context ?? [];
520
584
  let ctxToSave;
521
585
  if (config.saveContextTo === "ask") {
522
586
  if (typeof callbacks.onAskContext !== "function") {
@@ -560,10 +624,10 @@ function scenv(name, options = {}) {
560
624
  }
561
625
  contextName = await callbacks.onAskContext(
562
626
  name,
563
- config.contexts ?? []
627
+ config.context ?? []
564
628
  );
565
629
  }
566
- if (!contextName) contextName = config.contexts?.[0] ?? "default";
630
+ if (!contextName) contextName = config.context?.[0] ?? "default";
567
631
  writeToContext(contextName, key, String(validated.data));
568
632
  log("info", `Saved key=${key} to context ${contextName}`);
569
633
  }
@@ -577,9 +641,9 @@ function parseScenvArgs(argv) {
577
641
  while (i < argv.length) {
578
642
  const arg = argv[i];
579
643
  if (arg === "--context" && argv[i + 1] !== void 0) {
580
- config.contexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
644
+ config.context = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
581
645
  } else if (arg === "--add-context" && argv[i + 1] !== void 0) {
582
- config.addContexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
646
+ config.addContext = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
583
647
  } else if (arg === "--prompt" && argv[i + 1] !== void 0) {
584
648
  const v = argv[++i].toLowerCase();
585
649
  if (["always", "never", "fallback", "no-env"].includes(v)) {
@@ -603,6 +667,8 @@ function parseScenvArgs(argv) {
603
667
  }
604
668
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
605
669
  config.saveContextTo = argv[++i];
670
+ } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
671
+ config.contextDir = argv[++i];
606
672
  } else if ((arg === "--log-level" || arg === "--log") && argv[i + 1] !== void 0) {
607
673
  const v = argv[++i].toLowerCase();
608
674
  if (LOG_LEVELS.includes(v)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenv",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Environment and context variables with runtime-configurable resolution",
5
5
  "repository": {
6
6
  "type": "git",