scenv 0.5.0 → 0.6.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 CHANGED
@@ -28,9 +28,9 @@ const apiUrl = scenv("API URL", {
28
28
  default: "http://localhost:4000",
29
29
  });
30
30
 
31
- const url = await apiUrl.get(); // throws if missing or invalid
32
- const result = await apiUrl.safeGet(); // { success, value? } | { success: false, error? }
33
- await apiUrl.save(); // write current value to a context file
31
+ const url = await apiUrl.get(); // throws if missing or invalid
32
+ const result = await apiUrl.safeGet(); // { success, value? } | { success: false, error? }
33
+ await apiUrl.save(); // write current value to a context file
34
34
  ```
35
35
 
36
36
  ## Resolution order
@@ -46,21 +46,21 @@ Prompting (when to ask the user) is controlled by config `prompt`: `always` | `n
46
46
 
47
47
  ## Optional integrations
48
48
 
49
- | Package | Purpose |
50
- |--------|--------|
51
- | [scenv-zod](https://www.npmjs.com/package/scenv-zod) | `validator(zodSchema)` for type-safe validation and coercion. |
52
- | [scenv-inquirer](https://www.npmjs.com/package/scenv-inquirer) | `prompt()` and callbacks for interactive prompts. |
49
+ | Package | Purpose |
50
+ | -------------------------------------------------------------- | ------------------------------------------------------------- |
51
+ | [scenv-zod](https://www.npmjs.com/package/scenv-zod) | `validator(zodSchema)` for type-safe validation and coercion. |
52
+ | [scenv-inquirer](https://www.npmjs.com/package/scenv-inquirer) | `prompt()` and callbacks for interactive prompts. |
53
53
 
54
54
  ## Documentation
55
55
 
56
- Full docs (config, contexts, resolution, saving, API) live in the [monorepo](https://github.com/PKWadsy/senv):
56
+ Full docs (config, contexts, resolution, saving, API) live in the [monorepo](https://github.com/PKWadsy/scenv):
57
57
 
58
- - [Configuration](https://github.com/PKWadsy/senv/blob/main/docs/CONFIGURATION.md)
59
- - [Contexts](https://github.com/PKWadsy/senv/blob/main/docs/CONTEXTS.md)
60
- - [Resolution](https://github.com/PKWadsy/senv/blob/main/docs/RESOLUTION.md)
61
- - [Saving](https://github.com/PKWadsy/senv/blob/main/docs/SAVING.md)
62
- - [API reference](https://github.com/PKWadsy/senv/blob/main/docs/API.md)
63
- - [Integration (scenv-zod, scenv-inquirer)](https://github.com/PKWadsy/senv/blob/main/docs/INTEGRATION.md)
58
+ - [Configuration](https://github.com/PKWadsy/scenv/blob/main/docs/CONFIGURATION.md)
59
+ - [Contexts](https://github.com/PKWadsy/scenv/blob/main/docs/CONTEXTS.md)
60
+ - [Resolution](https://github.com/PKWadsy/scenv/blob/main/docs/RESOLUTION.md)
61
+ - [Saving](https://github.com/PKWadsy/scenv/blob/main/docs/SAVING.md)
62
+ - [API reference](https://github.com/PKWadsy/scenv/blob/main/docs/API.md)
63
+ - [Integration (scenv-zod, scenv-inquirer)](https://github.com/PKWadsy/scenv/blob/main/docs/INTEGRATION.md)
64
64
 
65
65
  ## License
66
66
 
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;
@@ -252,7 +265,7 @@ function logConfigLoaded(config) {
252
265
  "info",
253
266
  "config loaded",
254
267
  "root=" + (config.root ?? "(cwd)"),
255
- "contexts=" + JSON.stringify(config.contexts ?? [])
268
+ "context=" + JSON.stringify(config.context ?? [])
256
269
  );
257
270
  }
258
271
  function log(level, msg, ...args) {
@@ -338,7 +351,7 @@ function getMergedContextValues() {
338
351
  const root = config.root ?? process.cwd();
339
352
  const paths = discoverContextPaths(root);
340
353
  const out = {};
341
- for (const contextName of config.contexts ?? []) {
354
+ for (const contextName of config.context ?? []) {
342
355
  const filePath = paths.get(contextName);
343
356
  if (!filePath) {
344
357
  log("warn", `context "${contextName}" not found (no *.context.json)`);
@@ -370,7 +383,8 @@ function getContextWritePath(contextName) {
370
383
  const paths = discoverContextPaths(root);
371
384
  const existing = paths.get(contextName);
372
385
  if (existing) return existing;
373
- return (0, import_node_path2.join)(root, `${contextName}${CONTEXT_SUFFIX}`);
386
+ const saveDir = config.contextDir ? (0, import_node_path2.isAbsolute)(config.contextDir) ? config.contextDir : (0, import_node_path2.join)(root, config.contextDir) : root;
387
+ return (0, import_node_path2.join)(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
374
388
  }
375
389
  function writeToContext(contextName, key, value) {
376
390
  const path = getContextWritePath(contextName);
@@ -388,26 +402,49 @@ function writeToContext(contextName, key, value) {
388
402
  }
389
403
 
390
404
  // src/variable.ts
391
- var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
405
+ var CONTEXT_REF_FULL_REGEX = /^@([^:]+):(.+)$/;
406
+ var CONTEXT_REF_SHORT_REGEX = /^@([^:]+)$/;
407
+ var ENV_REF_REGEX = /\$([A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/g;
392
408
  var MAX_CONTEXT_REF_DEPTH = 10;
393
- function resolveContextReference(raw, depth = 0) {
409
+ function expandEnvReferences(str) {
410
+ return str.replace(ENV_REF_REGEX, (_, name) => {
411
+ const key = name.startsWith("{") ? name.slice(1, -1) : name;
412
+ return process.env[key] ?? "";
413
+ });
414
+ }
415
+ function resolveContextReference(raw, currentKey, depth = 0) {
394
416
  if (depth >= MAX_CONTEXT_REF_DEPTH) {
395
417
  throw new Error(
396
418
  `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
397
419
  );
398
420
  }
399
- const match = raw.match(CONTEXT_REF_REGEX);
400
- if (!match) return raw;
401
- const [, contextName, refKey] = match;
421
+ const afterEnv = expandEnvReferences(raw);
422
+ let contextName;
423
+ let refKey;
424
+ const fullMatch = afterEnv.match(CONTEXT_REF_FULL_REGEX);
425
+ if (fullMatch) {
426
+ [, contextName, refKey] = fullMatch;
427
+ } else {
428
+ const shortMatch = afterEnv.match(CONTEXT_REF_SHORT_REGEX);
429
+ if (!shortMatch) return afterEnv;
430
+ contextName = shortMatch[1];
431
+ if (currentKey === void 0) {
432
+ throw new Error(
433
+ `Context reference @${contextName} (short form) cannot be resolved without a variable key. Use @${contextName}:<key> to specify a key.`
434
+ );
435
+ }
436
+ refKey = currentKey;
437
+ }
402
438
  const ctx = getContext(contextName);
403
439
  const resolved = ctx[refKey];
404
440
  if (resolved === void 0) {
405
441
  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).`;
442
+ const displayRef = refKey === currentKey ? `@${contextName}` : `@${contextName}:${refKey}`;
443
+ 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
444
  log("error", msg);
408
445
  throw new Error(msg);
409
446
  }
410
- return resolveContextReference(resolved, depth + 1);
447
+ return resolveContextReference(resolved, currentKey, depth + 1);
411
448
  }
412
449
  function defaultKeyFromName(name) {
413
450
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
@@ -433,7 +470,7 @@ function scenv(name, options = {}) {
433
470
  log("trace", `resolveRaw: checking set for key=${key}`);
434
471
  if (config.set?.[key] !== void 0) {
435
472
  log("trace", `resolveRaw: set hit key=${key}`);
436
- const raw = resolveContextReference(config.set[key]);
473
+ const raw = resolveContextReference(config.set[key], key);
437
474
  return { raw, source: "set" };
438
475
  }
439
476
  if (!config.ignoreEnv) {
@@ -441,7 +478,7 @@ function scenv(name, options = {}) {
441
478
  const envVal = process.env[envKey];
442
479
  if (envVal !== void 0 && envVal !== "") {
443
480
  log("trace", "resolveRaw: env hit");
444
- const raw = resolveContextReference(envVal);
481
+ const raw = resolveContextReference(envVal, key);
445
482
  return { raw, source: "env" };
446
483
  }
447
484
  }
@@ -450,7 +487,7 @@ function scenv(name, options = {}) {
450
487
  const ctx = getMergedContextValues();
451
488
  if (ctx[key] !== void 0) {
452
489
  log("trace", `resolveRaw: context hit key=${key}`);
453
- const raw = resolveContextReference(ctx[key]);
490
+ const raw = resolveContextReference(ctx[key], key);
454
491
  return { raw, source: "context" };
455
492
  }
456
493
  }
@@ -477,7 +514,7 @@ function scenv(name, options = {}) {
477
514
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
478
515
  );
479
516
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
480
- const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
517
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault, key) : effectiveDefault;
481
518
  let wasPrompted = false;
482
519
  let value;
483
520
  let resolvedFrom;
@@ -491,7 +528,7 @@ function scenv(name, options = {}) {
491
528
  }
492
529
  const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
493
530
  let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
494
- value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
531
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue, key) : promptedValue;
495
532
  wasPrompted = true;
496
533
  resolvedFrom = "prompt";
497
534
  } else if (raw !== void 0) {
@@ -546,7 +583,7 @@ function scenv(name, options = {}) {
546
583
  }
547
584
  if (!doSave) return final;
548
585
  const callbacks = getCallbacks();
549
- const contextNames = config.contexts ?? [];
586
+ const contextNames = config.context ?? [];
550
587
  let ctxToSave;
551
588
  if (config.saveContextTo === "ask") {
552
589
  if (typeof callbacks.onAskContext !== "function") {
@@ -590,10 +627,10 @@ function scenv(name, options = {}) {
590
627
  }
591
628
  contextName = await callbacks.onAskContext(
592
629
  name,
593
- config.contexts ?? []
630
+ config.context ?? []
594
631
  );
595
632
  }
596
- if (!contextName) contextName = config.contexts?.[0] ?? "default";
633
+ if (!contextName) contextName = config.context?.[0] ?? "default";
597
634
  writeToContext(contextName, key, String(validated.data));
598
635
  log("info", `Saved key=${key} to context ${contextName}`);
599
636
  }
@@ -607,9 +644,9 @@ function parseScenvArgs(argv) {
607
644
  while (i < argv.length) {
608
645
  const arg = argv[i];
609
646
  if (arg === "--context" && argv[i + 1] !== void 0) {
610
- config.contexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
647
+ config.context = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
611
648
  } else if (arg === "--add-context" && argv[i + 1] !== void 0) {
612
- config.addContexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
649
+ config.addContext = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
613
650
  } else if (arg === "--prompt" && argv[i + 1] !== void 0) {
614
651
  const v = argv[++i].toLowerCase();
615
652
  if (["always", "never", "fallback", "no-env"].includes(v)) {
@@ -633,6 +670,8 @@ function parseScenvArgs(argv) {
633
670
  }
634
671
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
635
672
  config.saveContextTo = argv[++i];
673
+ } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
674
+ config.contextDir = argv[++i];
636
675
  } else if ((arg === "--log-level" || arg === "--log") && argv[i + 1] !== void 0) {
637
676
  const v = argv[++i].toLowerCase();
638
677
  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;
@@ -216,7 +229,7 @@ function logConfigLoaded(config) {
216
229
  "info",
217
230
  "config loaded",
218
231
  "root=" + (config.root ?? "(cwd)"),
219
- "contexts=" + JSON.stringify(config.contexts ?? [])
232
+ "context=" + JSON.stringify(config.context ?? [])
220
233
  );
221
234
  }
222
235
  function log(level, msg, ...args) {
@@ -239,7 +252,7 @@ import {
239
252
  readdirSync,
240
253
  statSync
241
254
  } from "fs";
242
- import { join as join2, dirname as dirname2 } from "path";
255
+ import { join as join2, dirname as dirname2, isAbsolute } from "path";
243
256
  var CONTEXT_SUFFIX = ".context.json";
244
257
  function discoverContextPathsInternal(dir, found) {
245
258
  let entries;
@@ -308,7 +321,7 @@ function getMergedContextValues() {
308
321
  const root = config.root ?? process.cwd();
309
322
  const paths = discoverContextPaths(root);
310
323
  const out = {};
311
- for (const contextName of config.contexts ?? []) {
324
+ for (const contextName of config.context ?? []) {
312
325
  const filePath = paths.get(contextName);
313
326
  if (!filePath) {
314
327
  log("warn", `context "${contextName}" not found (no *.context.json)`);
@@ -340,7 +353,8 @@ function getContextWritePath(contextName) {
340
353
  const paths = discoverContextPaths(root);
341
354
  const existing = paths.get(contextName);
342
355
  if (existing) return existing;
343
- return join2(root, `${contextName}${CONTEXT_SUFFIX}`);
356
+ const saveDir = config.contextDir ? isAbsolute(config.contextDir) ? config.contextDir : join2(root, config.contextDir) : root;
357
+ return join2(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
344
358
  }
345
359
  function writeToContext(contextName, key, value) {
346
360
  const path = getContextWritePath(contextName);
@@ -358,26 +372,49 @@ function writeToContext(contextName, key, value) {
358
372
  }
359
373
 
360
374
  // src/variable.ts
361
- var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
375
+ var CONTEXT_REF_FULL_REGEX = /^@([^:]+):(.+)$/;
376
+ var CONTEXT_REF_SHORT_REGEX = /^@([^:]+)$/;
377
+ var ENV_REF_REGEX = /\$([A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/g;
362
378
  var MAX_CONTEXT_REF_DEPTH = 10;
363
- function resolveContextReference(raw, depth = 0) {
379
+ function expandEnvReferences(str) {
380
+ return str.replace(ENV_REF_REGEX, (_, name) => {
381
+ const key = name.startsWith("{") ? name.slice(1, -1) : name;
382
+ return process.env[key] ?? "";
383
+ });
384
+ }
385
+ function resolveContextReference(raw, currentKey, depth = 0) {
364
386
  if (depth >= MAX_CONTEXT_REF_DEPTH) {
365
387
  throw new Error(
366
388
  `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
367
389
  );
368
390
  }
369
- const match = raw.match(CONTEXT_REF_REGEX);
370
- if (!match) return raw;
371
- const [, contextName, refKey] = match;
391
+ const afterEnv = expandEnvReferences(raw);
392
+ let contextName;
393
+ let refKey;
394
+ const fullMatch = afterEnv.match(CONTEXT_REF_FULL_REGEX);
395
+ if (fullMatch) {
396
+ [, contextName, refKey] = fullMatch;
397
+ } else {
398
+ const shortMatch = afterEnv.match(CONTEXT_REF_SHORT_REGEX);
399
+ if (!shortMatch) return afterEnv;
400
+ contextName = shortMatch[1];
401
+ if (currentKey === void 0) {
402
+ throw new Error(
403
+ `Context reference @${contextName} (short form) cannot be resolved without a variable key. Use @${contextName}:<key> to specify a key.`
404
+ );
405
+ }
406
+ refKey = currentKey;
407
+ }
372
408
  const ctx = getContext(contextName);
373
409
  const resolved = ctx[refKey];
374
410
  if (resolved === void 0) {
375
411
  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).`;
412
+ const displayRef = refKey === currentKey ? `@${contextName}` : `@${contextName}:${refKey}`;
413
+ 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
414
  log("error", msg);
378
415
  throw new Error(msg);
379
416
  }
380
- return resolveContextReference(resolved, depth + 1);
417
+ return resolveContextReference(resolved, currentKey, depth + 1);
381
418
  }
382
419
  function defaultKeyFromName(name) {
383
420
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
@@ -403,7 +440,7 @@ function scenv(name, options = {}) {
403
440
  log("trace", `resolveRaw: checking set for key=${key}`);
404
441
  if (config.set?.[key] !== void 0) {
405
442
  log("trace", `resolveRaw: set hit key=${key}`);
406
- const raw = resolveContextReference(config.set[key]);
443
+ const raw = resolveContextReference(config.set[key], key);
407
444
  return { raw, source: "set" };
408
445
  }
409
446
  if (!config.ignoreEnv) {
@@ -411,7 +448,7 @@ function scenv(name, options = {}) {
411
448
  const envVal = process.env[envKey];
412
449
  if (envVal !== void 0 && envVal !== "") {
413
450
  log("trace", "resolveRaw: env hit");
414
- const raw = resolveContextReference(envVal);
451
+ const raw = resolveContextReference(envVal, key);
415
452
  return { raw, source: "env" };
416
453
  }
417
454
  }
@@ -420,7 +457,7 @@ function scenv(name, options = {}) {
420
457
  const ctx = getMergedContextValues();
421
458
  if (ctx[key] !== void 0) {
422
459
  log("trace", `resolveRaw: context hit key=${key}`);
423
- const raw = resolveContextReference(ctx[key]);
460
+ const raw = resolveContextReference(ctx[key], key);
424
461
  return { raw, source: "context" };
425
462
  }
426
463
  }
@@ -447,7 +484,7 @@ function scenv(name, options = {}) {
447
484
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
448
485
  );
449
486
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
450
- const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
487
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault, key) : effectiveDefault;
451
488
  let wasPrompted = false;
452
489
  let value;
453
490
  let resolvedFrom;
@@ -461,7 +498,7 @@ function scenv(name, options = {}) {
461
498
  }
462
499
  const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
463
500
  let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
464
- value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
501
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue, key) : promptedValue;
465
502
  wasPrompted = true;
466
503
  resolvedFrom = "prompt";
467
504
  } else if (raw !== void 0) {
@@ -516,7 +553,7 @@ function scenv(name, options = {}) {
516
553
  }
517
554
  if (!doSave) return final;
518
555
  const callbacks = getCallbacks();
519
- const contextNames = config.contexts ?? [];
556
+ const contextNames = config.context ?? [];
520
557
  let ctxToSave;
521
558
  if (config.saveContextTo === "ask") {
522
559
  if (typeof callbacks.onAskContext !== "function") {
@@ -560,10 +597,10 @@ function scenv(name, options = {}) {
560
597
  }
561
598
  contextName = await callbacks.onAskContext(
562
599
  name,
563
- config.contexts ?? []
600
+ config.context ?? []
564
601
  );
565
602
  }
566
- if (!contextName) contextName = config.contexts?.[0] ?? "default";
603
+ if (!contextName) contextName = config.context?.[0] ?? "default";
567
604
  writeToContext(contextName, key, String(validated.data));
568
605
  log("info", `Saved key=${key} to context ${contextName}`);
569
606
  }
@@ -577,9 +614,9 @@ function parseScenvArgs(argv) {
577
614
  while (i < argv.length) {
578
615
  const arg = argv[i];
579
616
  if (arg === "--context" && argv[i + 1] !== void 0) {
580
- config.contexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
617
+ config.context = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
581
618
  } else if (arg === "--add-context" && argv[i + 1] !== void 0) {
582
- config.addContexts = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
619
+ config.addContext = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
583
620
  } else if (arg === "--prompt" && argv[i + 1] !== void 0) {
584
621
  const v = argv[++i].toLowerCase();
585
622
  if (["always", "never", "fallback", "no-env"].includes(v)) {
@@ -603,6 +640,8 @@ function parseScenvArgs(argv) {
603
640
  }
604
641
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
605
642
  config.saveContextTo = argv[++i];
643
+ } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
644
+ config.contextDir = argv[++i];
606
645
  } else if ((arg === "--log-level" || arg === "--log") && argv[i + 1] !== void 0) {
607
646
  const v = argv[++i].toLowerCase();
608
647
  if (LOG_LEVELS.includes(v)) {
package/package.json CHANGED
@@ -1,10 +1,21 @@
1
1
  {
2
2
  "name": "scenv",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Environment and context variables with runtime-configurable resolution",
5
- "repository": { "type": "git", "url": "https://github.com/PKWadsy/scenv" },
6
- "publishConfig": { "access": "public", "provenance": true },
7
- "keywords": ["env", "config", "context", "variables"],
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PKWadsy/scenv"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "provenance": true
12
+ },
13
+ "keywords": [
14
+ "env",
15
+ "config",
16
+ "context",
17
+ "variables"
18
+ ],
8
19
  "type": "module",
9
20
  "main": "dist/index.cjs",
10
21
  "module": "dist/index.js",