scenv 0.4.2 → 0.5.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/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
@@ -40,25 +40,27 @@ await apiUrl.save(); // write current value to a context file
40
40
  3. **Context** – merged JSON context files
41
41
  4. **Default** – variable’s `default` option
42
42
 
43
+ Any string value matching **`@<context>:<key>`** (e.g. `@prod:core_server_url`) is resolved from that context file first—in set, env, context, default, and prompts.
44
+
43
45
  Prompting (when to ask the user) is controlled by config `prompt`: `always` | `never` | `fallback` | `no-env`.
44
46
 
45
47
  ## Optional integrations
46
48
 
47
- | Package | Purpose |
48
- |--------|--------|
49
- | [scenv-zod](https://www.npmjs.com/package/scenv-zod) | `validator(zodSchema)` for type-safe validation and coercion. |
50
- | [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. |
51
53
 
52
54
  ## Documentation
53
55
 
54
- 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):
55
57
 
56
- - [Configuration](https://github.com/PKWadsy/senv/blob/main/docs/CONFIGURATION.md)
57
- - [Contexts](https://github.com/PKWadsy/senv/blob/main/docs/CONTEXTS.md)
58
- - [Resolution](https://github.com/PKWadsy/senv/blob/main/docs/RESOLUTION.md)
59
- - [Saving](https://github.com/PKWadsy/senv/blob/main/docs/SAVING.md)
60
- - [API reference](https://github.com/PKWadsy/senv/blob/main/docs/API.md)
61
- - [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)
62
64
 
63
65
  ## License
64
66
 
package/dist/index.cjs CHANGED
@@ -24,7 +24,8 @@ __export(index_exports, {
24
24
  configure: () => configure,
25
25
  discoverContextPaths: () => discoverContextPaths,
26
26
  getCallbacks: () => getCallbacks,
27
- getContextValues: () => getContextValues,
27
+ getContext: () => getContext,
28
+ getMergedContextValues: () => getMergedContextValues,
28
29
  loadConfig: () => loadConfig,
29
30
  parseScenvArgs: () => parseScenvArgs,
30
31
  resetConfig: () => resetConfig,
@@ -36,6 +37,52 @@ module.exports = __toCommonJS(index_exports);
36
37
  // src/config.ts
37
38
  var import_node_fs = require("fs");
38
39
  var import_node_path = require("path");
40
+
41
+ // src/prompt-default.ts
42
+ var import_node_readline = require("readline");
43
+ function ask(message) {
44
+ const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
45
+ return new Promise((resolve, reject) => {
46
+ rl.question(message, (answer) => {
47
+ rl.close();
48
+ resolve(answer.trim());
49
+ });
50
+ rl.on("error", (err) => {
51
+ rl.close();
52
+ reject(err);
53
+ });
54
+ });
55
+ }
56
+ function defaultPrompt(name, defaultValue) {
57
+ const defaultStr = defaultValue !== void 0 && defaultValue !== null ? String(defaultValue) : "";
58
+ const message = defaultStr ? `Enter ${name} [${defaultStr}]: ` : `Enter ${name}: `;
59
+ const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
60
+ return new Promise((resolve, reject) => {
61
+ rl.question(message, (answer) => {
62
+ rl.close();
63
+ const trimmed = answer.trim();
64
+ const value = trimmed !== "" ? trimmed : defaultStr;
65
+ resolve(value);
66
+ });
67
+ rl.on("error", (err) => {
68
+ rl.close();
69
+ reject(err);
70
+ });
71
+ });
72
+ }
73
+ async function defaultAskWhetherToSave(name, _value) {
74
+ const answer = await ask(`Save "${name}" for next time? (y/n): `);
75
+ const v = answer.toLowerCase();
76
+ return v === "y" || v === "yes" || v === "1" || v === "true";
77
+ }
78
+ async function defaultAskContext(name, contextNames) {
79
+ const hint = contextNames.length > 0 ? ` (${contextNames.join(", ")})` : "";
80
+ const answer = await ask(`Save "${name}" to which context?${hint}: `);
81
+ if (answer) return answer;
82
+ return contextNames[0] ?? "default";
83
+ }
84
+
85
+ // src/config.ts
39
86
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
40
87
  var CONFIG_FILENAME = "scenv.config.json";
41
88
  var envKeyMap = {
@@ -44,14 +91,18 @@ var envKeyMap = {
44
91
  SCENV_PROMPT: "prompt",
45
92
  SCENV_IGNORE_ENV: "ignoreEnv",
46
93
  SCENV_IGNORE_CONTEXT: "ignoreContext",
47
- SCENV_SAVE_PROMPT: "savePrompt",
94
+ SCENV_SAVE_PROMPT: "shouldSavePrompt",
48
95
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
49
96
  SCENV_LOG_LEVEL: "logLevel"
50
97
  };
51
98
  var programmaticConfig = {};
52
99
  var programmaticCallbacks = {};
53
100
  function getCallbacks() {
54
- return { ...programmaticCallbacks };
101
+ return {
102
+ defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt,
103
+ onAskWhetherToSave: programmaticCallbacks.onAskWhetherToSave ?? defaultAskWhetherToSave,
104
+ onAskContext: programmaticCallbacks.onAskContext ?? defaultAskContext
105
+ };
55
106
  }
56
107
  function findConfigDir(startDir) {
57
108
  let dir = startDir;
@@ -73,11 +124,11 @@ function configFromEnv() {
73
124
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
74
125
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
75
126
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
76
- } else if (configKey === "prompt" || configKey === "savePrompt") {
127
+ } else if (configKey === "prompt" || configKey === "shouldSavePrompt") {
77
128
  const v = val.toLowerCase();
78
129
  if (configKey === "prompt" && (v === "always" || v === "never" || v === "fallback" || v === "no-env"))
79
130
  out[configKey] = v;
80
- if (configKey === "savePrompt" && (v === "always" || v === "never" || v === "ask"))
131
+ if (configKey === "shouldSavePrompt" && (v === "always" || v === "never" || v === "ask"))
81
132
  out[configKey] = v;
82
133
  } else if (configKey === "saveContextTo") {
83
134
  out.saveContextTo = val;
@@ -110,8 +161,8 @@ function loadConfigFile(configDir) {
110
161
  out.ignoreContext = parsed.ignoreContext;
111
162
  if (parsed.set && typeof parsed.set === "object" && !Array.isArray(parsed.set))
112
163
  out.set = parsed.set;
113
- if (typeof parsed.savePrompt === "string" && ["always", "never", "ask"].includes(parsed.savePrompt))
114
- out.savePrompt = parsed.savePrompt;
164
+ if (typeof (parsed.shouldSavePrompt ?? parsed.savePrompt) === "string" && ["always", "never", "ask"].includes(parsed.shouldSavePrompt ?? parsed.savePrompt))
165
+ out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
115
166
  if (typeof parsed.saveContextTo === "string")
116
167
  out.saveContextTo = parsed.saveContextTo;
117
168
  if (typeof parsed.root === "string") out.root = parsed.root;
@@ -255,7 +306,32 @@ function discoverContextPaths(dir, found = /* @__PURE__ */ new Map()) {
255
306
  );
256
307
  return found;
257
308
  }
258
- function getContextValues() {
309
+ function getContext(contextName, root) {
310
+ const config = loadConfig();
311
+ const searchRoot = root ?? config.root ?? process.cwd();
312
+ const paths = discoverContextPaths(searchRoot);
313
+ const filePath = paths.get(contextName);
314
+ if (!filePath) {
315
+ log("trace", `getContext: context "${contextName}" not found`);
316
+ return {};
317
+ }
318
+ try {
319
+ const raw = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
320
+ const data = JSON.parse(raw);
321
+ const out = {};
322
+ for (const [k, v] of Object.entries(data)) {
323
+ if (typeof v === "string") out[k] = v;
324
+ }
325
+ return out;
326
+ } catch (err) {
327
+ log(
328
+ "trace",
329
+ `getContext: "${contextName}" unreadable: ${err instanceof Error ? err.message : String(err)}`
330
+ );
331
+ return {};
332
+ }
333
+ }
334
+ function getMergedContextValues() {
259
335
  const config = loadConfig();
260
336
  logConfigLoaded(config);
261
337
  if (config.ignoreContext) return {};
@@ -312,6 +388,27 @@ function writeToContext(contextName, key, value) {
312
388
  }
313
389
 
314
390
  // src/variable.ts
391
+ var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
392
+ var MAX_CONTEXT_REF_DEPTH = 10;
393
+ function resolveContextReference(raw, depth = 0) {
394
+ if (depth >= MAX_CONTEXT_REF_DEPTH) {
395
+ throw new Error(
396
+ `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
397
+ );
398
+ }
399
+ const match = raw.match(CONTEXT_REF_REGEX);
400
+ if (!match) return raw;
401
+ const [, contextName, refKey] = match;
402
+ const ctx = getContext(contextName);
403
+ const resolved = ctx[refKey];
404
+ if (resolved === void 0) {
405
+ 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).`;
407
+ log("error", msg);
408
+ throw new Error(msg);
409
+ }
410
+ return resolveContextReference(resolved, depth + 1);
411
+ }
315
412
  function defaultKeyFromName(name) {
316
413
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
317
414
  }
@@ -336,22 +433,25 @@ function scenv(name, options = {}) {
336
433
  log("trace", `resolveRaw: checking set for key=${key}`);
337
434
  if (config.set?.[key] !== void 0) {
338
435
  log("trace", `resolveRaw: set hit key=${key}`);
339
- return { raw: config.set[key], source: "set" };
436
+ const raw = resolveContextReference(config.set[key]);
437
+ return { raw, source: "set" };
340
438
  }
341
439
  if (!config.ignoreEnv) {
342
440
  log("trace", `resolveRaw: checking env ${envKey}`);
343
441
  const envVal = process.env[envKey];
344
442
  if (envVal !== void 0 && envVal !== "") {
345
443
  log("trace", "resolveRaw: env hit");
346
- return { raw: envVal, source: "env" };
444
+ const raw = resolveContextReference(envVal);
445
+ return { raw, source: "env" };
347
446
  }
348
447
  }
349
448
  if (!config.ignoreContext) {
350
449
  log("trace", "resolveRaw: checking context");
351
- const ctx = getContextValues();
450
+ const ctx = getMergedContextValues();
352
451
  if (ctx[key] !== void 0) {
353
452
  log("trace", `resolveRaw: context hit key=${key}`);
354
- return { raw: ctx[key], source: "context" };
453
+ const raw = resolveContextReference(ctx[key]);
454
+ return { raw, source: "context" };
355
455
  }
356
456
  }
357
457
  log("trace", "resolveRaw: no value");
@@ -377,6 +477,7 @@ function scenv(name, options = {}) {
377
477
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
378
478
  );
379
479
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
480
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
380
481
  let wasPrompted = false;
381
482
  let value;
382
483
  let resolvedFrom;
@@ -388,15 +489,16 @@ function scenv(name, options = {}) {
388
489
  `Prompt required for variable "${name}" (key: ${key}) but no prompt was supplied and no defaultPrompt callback is configured. Set a prompt on the variable or configure({ callbacks: { defaultPrompt: ... } }).`
389
490
  );
390
491
  }
391
- const defaultForPrompt = raw !== void 0 ? raw : effectiveDefault;
392
- value = await Promise.resolve(fn(name, defaultForPrompt));
492
+ const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
493
+ let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
494
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
393
495
  wasPrompted = true;
394
496
  resolvedFrom = "prompt";
395
497
  } else if (raw !== void 0) {
396
498
  value = raw;
397
499
  resolvedFrom = source;
398
- } else if (effectiveDefault !== void 0) {
399
- value = effectiveDefault;
500
+ } else if (resolvedDefault !== void 0) {
501
+ value = resolvedDefault;
400
502
  resolvedFrom = "default";
401
503
  } else {
402
504
  throw new Error(`Missing value for variable "${name}" (key: ${key})`);
@@ -428,25 +530,36 @@ function scenv(name, options = {}) {
428
530
  const final = validated.data;
429
531
  if (wasPrompted) {
430
532
  const config = loadConfig();
431
- const savePrompt = config.savePrompt ?? (config.prompt === "never" ? "never" : "ask");
432
- const shouldAskSave = savePrompt === "always" || savePrompt === "ask" && wasPrompted;
433
- if (shouldAskSave) {
434
- const callbacks = getCallbacks();
435
- if (typeof callbacks.onAskSaveAfterPrompt !== "function") {
533
+ const mode = config.shouldSavePrompt ?? (config.prompt === "never" ? "never" : "ask");
534
+ if (mode === "never") return final;
535
+ let doSave;
536
+ if (mode === "ask") {
537
+ const callbacks2 = getCallbacks();
538
+ if (typeof callbacks2.onAskWhetherToSave !== "function") {
436
539
  throw new Error(
437
- `savePrompt is "${savePrompt}" but onAskSaveAfterPrompt callback is not set. Configure callbacks via configure({ callbacks: { onAskSaveAfterPrompt: ... } }).`
540
+ `shouldSavePrompt is "ask" but onAskWhetherToSave callback is not set. Configure callbacks via configure({ callbacks: { onAskWhetherToSave: ... } }).`
438
541
  );
439
542
  }
440
- const ctxToSave = await callbacks.onAskSaveAfterPrompt(
441
- name,
442
- final,
443
- config.contexts ?? []
444
- );
445
- if (ctxToSave) {
446
- writeToContext(ctxToSave, key, String(final));
447
- log("info", `Saved key=${key} to context ${ctxToSave}`);
543
+ doSave = await callbacks2.onAskWhetherToSave(name, final);
544
+ } else {
545
+ doSave = true;
546
+ }
547
+ if (!doSave) return final;
548
+ const callbacks = getCallbacks();
549
+ const contextNames = config.contexts ?? [];
550
+ let ctxToSave;
551
+ if (config.saveContextTo === "ask") {
552
+ if (typeof callbacks.onAskContext !== "function") {
553
+ throw new Error(
554
+ `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
555
+ );
448
556
  }
557
+ ctxToSave = await callbacks.onAskContext(name, contextNames);
558
+ } else {
559
+ ctxToSave = config.saveContextTo ?? contextNames[0] ?? "default";
449
560
  }
561
+ writeToContext(ctxToSave, key, String(final));
562
+ log("info", `Saved key=${key} to context ${ctxToSave}`);
450
563
  }
451
564
  return final;
452
565
  }
@@ -516,7 +629,7 @@ function parseScenvArgs(argv) {
516
629
  } else if (arg === "--save-prompt" && argv[i + 1] !== void 0) {
517
630
  const v = argv[++i].toLowerCase();
518
631
  if (["always", "never", "ask"].includes(v)) {
519
- config.savePrompt = v;
632
+ config.shouldSavePrompt = v;
520
633
  }
521
634
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
522
635
  config.saveContextTo = argv[++i];
@@ -548,7 +661,8 @@ function parseScenvArgs(argv) {
548
661
  configure,
549
662
  discoverContextPaths,
550
663
  getCallbacks,
551
- getContextValues,
664
+ getContext,
665
+ getMergedContextValues,
552
666
  loadConfig,
553
667
  parseScenvArgs,
554
668
  resetConfig,
package/dist/index.d.cts CHANGED
@@ -1,67 +1,148 @@
1
+ /**
2
+ * When to prompt the user for a variable value during resolution.
3
+ * - `"always"` – Always call the variable's prompt (or defaultPrompt) when resolving.
4
+ * - `"never"` – Never prompt; use set/env/context/default only.
5
+ * - `"fallback"` – Prompt only when no value was found from set, env, or context.
6
+ * - `"no-env"` – Prompt when the env var is not set (even if context has a value).
7
+ */
1
8
  type PromptMode = "always" | "never" | "fallback" | "no-env";
9
+ /**
10
+ * What to do with the value after the user was just prompted for it.
11
+ * - `"never"` – Do not save; discard for next time.
12
+ * - `"always"` – Save it (no prompt). Use saveContextTo or onAskContext only to pick where.
13
+ * - `"ask"` – Call {@link ScenvCallbacks.onAskWhetherToSave}; if true save, if false don't save.
14
+ */
2
15
  type SavePromptMode = "always" | "never" | "ask";
16
+ /**
17
+ * Valid log levels. Use with {@link ScenvConfig.logLevel}.
18
+ * Messages at or above the configured level are written to stderr.
19
+ */
3
20
  declare const LOG_LEVELS: readonly ["none", "trace", "debug", "info", "warn", "error"];
21
+ /** Log level type. `"none"` disables logging; higher values are more verbose. */
4
22
  type LogLevel = (typeof LOG_LEVELS)[number];
23
+ /**
24
+ * Full scenv configuration. Built from file (scenv.config.json), environment (SCENV_*),
25
+ * and programmatic config (configure()), with programmatic > env > file precedence.
26
+ * All properties are optional; defaults apply when omitted.
27
+ */
5
28
  interface ScenvConfig {
6
- /** Replace all contexts with this list (CLI: --context a,b,c) */
29
+ /** Replace the context list entirely (CLI: `--context a,b,c`). Loaded in order; later overwrites earlier for same key. */
7
30
  contexts?: string[];
8
- /** Merge these contexts with existing (CLI: --add-context a,b,c) */
31
+ /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `contexts` is set in the same layer. */
9
32
  addContexts?: string[];
10
- /** When to prompt for variable value */
33
+ /** When to prompt for a variable value. See {@link PromptMode}. Default is `"fallback"`. */
11
34
  prompt?: PromptMode;
12
- /** Ignore environment variables during resolution */
35
+ /** If true, environment variables are not used during resolution. */
13
36
  ignoreEnv?: boolean;
14
- /** Ignore loaded context during resolution */
37
+ /** If true, context files are not loaded during resolution. */
15
38
  ignoreContext?: boolean;
16
- /** Override values: key -> string (CLI: --set key=val) */
39
+ /** Override values by key (CLI: `--set key=value`). Takes precedence over env and context. */
17
40
  set?: Record<string, string>;
18
- /** When to ask "save for next time?" */
19
- savePrompt?: SavePromptMode;
20
- /** Where to save: context name or "ask" */
41
+ /** After a prompt: "never" = don't save, "always" = save without asking, "ask" = call onAskWhetherToSave (then save if true). */
42
+ shouldSavePrompt?: SavePromptMode;
43
+ /** Target context for saving: a context name, or `"ask"` to use {@link ScenvCallbacks.onAskContext}. */
21
44
  saveContextTo?: "ask" | string;
22
- /** Root directory for config/context search (default: cwd) */
45
+ /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
23
46
  root?: string;
24
- /** Log level: none (default), trace, debug, info, warn, error */
47
+ /** Logging level. Default is `"none"`. Messages go to stderr. */
25
48
  logLevel?: LogLevel;
26
49
  }
27
- /** (name, defaultValue) => value; used when a variable has no prompt option. Overridable per variable. */
50
+ /**
51
+ * Default prompt function signature. Called when a variable has no `prompt` option and
52
+ * config requests prompting. Receives the variable's display name and default value;
53
+ * returns the value to use (sync or async). Overridable per variable via the variable's
54
+ * `prompt` option or per call via get({ prompt: fn }).
55
+ */
28
56
  type DefaultPromptFn = (name: string, defaultValue: unknown) => unknown | Promise<unknown>;
57
+ /**
58
+ * Callbacks for interactive behaviour. All have built-in readline defaults when not set.
59
+ * Pass to {@link configure} via `configure({ callbacks: { ... } })`.
60
+ */
29
61
  interface ScenvCallbacks {
30
- /** Default prompt when a variable does not provide its own `prompt`. Variable's `prompt` overrides this. */
62
+ /** Used when a variable does not define its own `prompt`. Variable-level `prompt` overrides this. */
31
63
  defaultPrompt?: DefaultPromptFn;
32
- /** When user was just prompted for a value and savePrompt is ask/always: (variableName, value, contextNames) => context name to save to, or null to skip */
33
- onAskSaveAfterPrompt?: (name: string, value: unknown, contextNames: string[]) => Promise<string | null>;
34
- /** When saveContextTo is "ask": (variableName, contextNames) => context name to save to */
64
+ /** Called only when {@link ScenvConfig.shouldSavePrompt} is "ask" and the user was just prompted. Return true to save, false to skip. (With "always", we save without calling this.) */
65
+ onAskWhetherToSave?: (name: string, value: unknown) => Promise<boolean>;
66
+ /** Called when the save destination is ambiguous: saveContextTo is "ask", or after a prompt when the user said yes and saveContextTo is "ask". Return the context name to write to. */
35
67
  onAskContext?: (name: string, contextNames: string[]) => Promise<string>;
36
68
  }
69
+ /**
70
+ * Returns the current callbacks (with built-in defaults merged in for any unset callback).
71
+ * Useful for inspection or to pass a subset to another layer. Each call returns a new object.
72
+ *
73
+ * @returns Copy of the effective callbacks: defaultPrompt, onAskWhetherToSave, onAskContext (always defined).
74
+ */
37
75
  declare function getCallbacks(): ScenvCallbacks;
38
76
  /**
39
- * Load full config: file (from root or cwd) <- env <- programmatic. Precedence: programmatic > env > file.
77
+ * Loads the full merged configuration. Searches for scenv.config.json upward from the given
78
+ * root (or cwd / programmatic root), then overlays SCENV_* env vars, then programmatic config.
79
+ * Use this to read the effective config (e.g. for logging or conditional logic).
80
+ *
81
+ * @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.
40
83
  */
41
84
  declare function loadConfig(root?: string): ScenvConfig;
42
85
  /**
43
- * Set programmatic config (e.g. from CLI flags). Merged on top of env and file in loadConfig().
86
+ * Merges config and/or callbacks into the programmatic layer. Programmatic config has
87
+ * highest precedence in {@link loadConfig}. Call multiple times to merge; later values
88
+ * overwrite earlier for the same key. Typical use: pass the result of {@link parseScenvArgs}
89
+ * or your own partial config.
90
+ *
91
+ * @param partial - Partial config and/or `callbacks`. Omitted keys are left unchanged. Nested objects (e.g. callbacks, set) are merged shallowly with existing.
44
92
  */
45
93
  declare function configure(partial: Partial<ScenvConfig> & {
46
94
  callbacks?: ScenvCallbacks;
47
95
  }): void;
48
96
  /**
49
- * Reset programmatic config (mainly for tests).
97
+ * Clears all programmatic config and callbacks. File and env config are unaffected.
98
+ * Mainly for tests. After calling, the next loadConfig() will not include any programmatic overrides.
50
99
  */
51
100
  declare function resetConfig(): void;
52
101
 
53
- /** Reset internal log state (e.g. config-loaded guard). Useful in tests after resetConfig(). */
102
+ /**
103
+ * Resets internal log state (e.g. the "config loaded" one-time guard). Call after
104
+ * {@link resetConfig} in tests if you need to see config-loaded messages again or
105
+ * assert on log output.
106
+ */
54
107
  declare function resetLogState(): void;
55
108
 
56
109
  /**
57
- * Recursively find all *.context.json files under dir. Returns map: contextName -> absolute path (first found wins).
110
+ * Recursively discovers all `*.context.json` files under a directory. Context name is the
111
+ * filename without the suffix (e.g. `dev.context.json` → "dev"). First file found for a
112
+ * given name wins. Used internally for loading and saving; you can call it to inspect
113
+ * available contexts.
114
+ *
115
+ * @param dir - Root directory to search (e.g. config.root or process.cwd()).
116
+ * @param found - Optional existing map to merge results into. If omitted, a new Map is used.
117
+ * @returns Map from context name to absolute file path.
58
118
  */
59
119
  declare function discoverContextPaths(dir: string, found?: Map<string, string>): Map<string, string>;
60
120
  /**
61
- * Load context files in the order of config.contexts; merge into one flat map (later context overwrites earlier for same key).
121
+ * 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
123
+ * under the config root.
124
+ *
125
+ * @param contextName - Name of the context (e.g. "prod", "dev") — file is contextName.context.json.
126
+ * @param root - Optional root directory to search. If omitted, uses loadConfig().root.
127
+ * @returns A flat record of key → string value from that context file. Empty if file not found or invalid.
128
+ */
129
+ declare function getContext(contextName: string, root?: string): Record<string, string>;
130
+ /**
131
+ * Loads and merges context values from the current config. Respects {@link ScenvConfig.contexts}
132
+ * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
133
+ * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
134
+ * resolution (set > env > context > default).
135
+ *
136
+ * @returns A flat record of key → string value. Empty if ignoreContext is true or no contexts loaded.
62
137
  */
63
- declare function getContextValues(): Record<string, string>;
138
+ declare function getMergedContextValues(): Record<string, string>;
64
139
 
140
+ /**
141
+ * Return type for a variable's optional `validator` function. Use a boolean for simple
142
+ * pass/fail, or an object to pass a transformed value or a custom error.
143
+ * - `true` or `{ success: true, data?: T }` – validation passed; optional `data` replaces the value.
144
+ * - `false` or `{ success: false, error?: unknown }` – validation failed; `.get()` throws with the error.
145
+ */
65
146
  type ValidatorResult<T> = boolean | {
66
147
  success: true;
67
148
  data?: T;
@@ -69,23 +150,51 @@ type ValidatorResult<T> = boolean | {
69
150
  success: false;
70
151
  error?: unknown;
71
152
  };
153
+ /**
154
+ * Prompt function signature. Called when config requests prompting for this variable.
155
+ * Receives the variable's display name and the current default (from set/env/context or option default).
156
+ * Return the value to use (sync or async). Used as the variable's `prompt` option or in get({ prompt: fn }).
157
+ */
72
158
  type PromptFn<T> = (name: string, defaultValue: T) => T | Promise<T>;
159
+ /**
160
+ * Options when creating a variable with {@link scenv}. All properties are optional.
161
+ * If you omit `key` and `env`, they are derived from `name`: e.g. "API URL" → key `api_url`, env `API_URL`.
162
+ */
73
163
  interface ScenvVariableOptions<T> {
164
+ /** Internal key for --set, context files, and env. Default: name lowercased, spaces → underscores, non-alphanumeric stripped (e.g. "API URL" → "api_url"). */
74
165
  key?: string;
166
+ /** Environment variable name (e.g. API_URL). Default: key uppercased, hyphens → underscores. */
75
167
  env?: string;
168
+ /** Fallback when nothing is provided via --set, env, or context (and we're not prompting). */
76
169
  default?: T;
170
+ /** Optional. Run after value is resolved or prompted. Return true / { success: true } to accept, false / { success: false, error } to reject (get() throws). Use to coerce types or enforce rules. */
77
171
  validator?: (val: T) => ValidatorResult<T>;
172
+ /** Optional. Called when config says to prompt (e.g. prompt: "fallback" and no value found). Overrides callbacks.defaultPrompt for this variable. */
78
173
  prompt?: PromptFn<T>;
79
174
  }
80
- /** Overrides for a single .get() or .safeGet() call. */
175
+ /**
176
+ * Overrides for a single get() or safeGet() call. Only that call is affected.
177
+ */
81
178
  interface GetOptions<T> {
82
- /** Use this prompt for this call instead of the variable's prompt or callbacks.defaultPrompt. */
179
+ /** Use this prompt for this call only (e.g. a one-off inquirer prompt). */
83
180
  prompt?: PromptFn<T>;
84
- /** Use this as the default for this call if no value from set/env/context. */
181
+ /** Use this as the default for this call when no value from set/env/context. */
85
182
  default?: T;
86
183
  }
184
+ /**
185
+ * A scenv variable: a named setting (e.g. "API URL") whose value is resolved from CLI overrides (--set),
186
+ * then environment variables, then context files, then default (or an optional prompt). You call get() or
187
+ * safeGet() to read the value; optionally save() to persist it to a context file for next time.
188
+ */
87
189
  interface ScenvVariable<T> {
190
+ /**
191
+ * Resolve and return the value. Uses resolution order (set > env > context > default) and any prompt/validator.
192
+ * @throws If no value is found and no default/prompt, or if validation fails.
193
+ */
88
194
  get(options?: GetOptions<T>): Promise<T>;
195
+ /**
196
+ * Like get(), but never throws. Returns { success: true, value } or { success: false, error }.
197
+ */
89
198
  safeGet(options?: GetOptions<T>): Promise<{
90
199
  success: true;
91
200
  value: T;
@@ -93,15 +202,71 @@ interface ScenvVariable<T> {
93
202
  success: false;
94
203
  error?: unknown;
95
204
  }>;
205
+ /**
206
+ * Write the value to a context file (e.g. for next run). Target context comes from config.saveContextTo or onAskContext.
207
+ * If you don't pass a value, the last resolved value is used. Does not prompt "save?"; it saves.
208
+ */
96
209
  save(value?: T): Promise<void>;
97
210
  }
211
+ /**
212
+ * Creates a scenv variable: a named config value (e.g. API URL, port) that you read with get() or safeGet()
213
+ * and optionally write with save(). You pass a display name and optional options; key and env are derived from
214
+ * the name if you omit them.
215
+ *
216
+ * ## Resolution (how get() gets a value)
217
+ *
218
+ * get() first looks for a raw value in this order, stopping at the first found:
219
+ * - Set overrides from config (e.g. from --set key=value or configure({ set: { key: "value" } }))
220
+ * - 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)
222
+ *
223
+ * If config says to prompt (see prompt mode in config: "always", "fallback", "no-env"), the prompt callback
224
+ * may run. When it runs, it receives the variable name and a suggested value (the raw value if any, otherwise
225
+ * the default option). The callback's return value is used as the value. When we don't prompt, we use the
226
+ * raw value if present, otherwise the default option, otherwise get() throws (no value).
227
+ *
228
+ * ## Validator
229
+ *
230
+ * If you pass a validator option, it is called with the resolved or prompted value. It can return true or
231
+ * { success: true } to accept, or false or { success: false, error } to reject; on reject, get() throws.
232
+ * Use it to coerce types (e.g. string to number) or enforce rules.
233
+ *
234
+ * ## save()
235
+ *
236
+ * The variable has a save(value?) method. It writes the value (or the last resolved value if you omit it)
237
+ * to a context file. The target context comes from config.saveContextTo or from the onAskContext callback
238
+ * when saveContextTo is "ask". save() does not ask "save?"; it saves. Optional "save after prompt" behavior
239
+ * is controlled by config.shouldSavePrompt and callbacks.onAskWhetherToSave.
240
+ *
241
+ * @typeParam T - Value type (default string). Use a validator to coerce to number, boolean, etc.
242
+ * @param name - Display name used in prompts and errors. If you omit key/env, key is derived from name (e.g. "API URL" → "api_url") and env from key (e.g. "API_URL").
243
+ * @param options - Optional. key, env, default, validator, prompt. See {@link ScenvVariableOptions}.
244
+ * @returns A {@link ScenvVariable} with get(), safeGet(), and save().
245
+ *
246
+ * @example
247
+ * const apiUrl = scenv("API URL", { default: "http://localhost:4000" });
248
+ * const url = await apiUrl.get();
249
+ */
98
250
  declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): ScenvVariable<T>;
99
251
 
100
252
  /**
101
- * Parse argv (e.g. process.argv.slice(2)) into ScenvConfig for configure().
102
- * Supports: --context a,b,c --add-context x,y --prompt fallback --ignore-env --ignore-context
103
- * --set key=value --save-prompt ask --save-context-to prod --log-level trace
253
+ * Parses command-line arguments into a partial {@link ScenvConfig} suitable for {@link configure}.
254
+ * Typical use: `configure(parseScenvArgs(process.argv.slice(2)))`. Unrecognized flags are ignored.
255
+ *
256
+ * Supported flags:
257
+ * - `--context a,b,c` – Set contexts (replace).
258
+ * - `--add-context x,y` – Add contexts.
259
+ * - `--prompt always|never|fallback|no-env` – Prompt mode.
260
+ * - `--ignore-env` – Set ignoreEnv to true.
261
+ * - `--ignore-context` – Set ignoreContext to true.
262
+ * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
263
+ * - `--save-prompt always|never|ask` – shouldSavePrompt.
264
+ * - `--save-context-to name` – saveContextTo.
265
+ * - `--log-level level`, `--log level`, `--log=level` – logLevel.
266
+ *
267
+ * @param argv - Array of CLI arguments (e.g. process.argv.slice(2)).
268
+ * @returns Partial ScenvConfig with only the keys that were present in argv.
104
269
  */
105
270
  declare function parseScenvArgs(argv: string[]): Partial<ScenvConfig>;
106
271
 
107
- export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type SavePromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContextValues, loadConfig, parseScenvArgs, resetConfig, resetLogState, scenv };
272
+ export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type SavePromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContext, getMergedContextValues, loadConfig, parseScenvArgs, resetConfig, resetLogState, scenv };
package/dist/index.d.ts CHANGED
@@ -1,67 +1,148 @@
1
+ /**
2
+ * When to prompt the user for a variable value during resolution.
3
+ * - `"always"` – Always call the variable's prompt (or defaultPrompt) when resolving.
4
+ * - `"never"` – Never prompt; use set/env/context/default only.
5
+ * - `"fallback"` – Prompt only when no value was found from set, env, or context.
6
+ * - `"no-env"` – Prompt when the env var is not set (even if context has a value).
7
+ */
1
8
  type PromptMode = "always" | "never" | "fallback" | "no-env";
9
+ /**
10
+ * What to do with the value after the user was just prompted for it.
11
+ * - `"never"` – Do not save; discard for next time.
12
+ * - `"always"` – Save it (no prompt). Use saveContextTo or onAskContext only to pick where.
13
+ * - `"ask"` – Call {@link ScenvCallbacks.onAskWhetherToSave}; if true save, if false don't save.
14
+ */
2
15
  type SavePromptMode = "always" | "never" | "ask";
16
+ /**
17
+ * Valid log levels. Use with {@link ScenvConfig.logLevel}.
18
+ * Messages at or above the configured level are written to stderr.
19
+ */
3
20
  declare const LOG_LEVELS: readonly ["none", "trace", "debug", "info", "warn", "error"];
21
+ /** Log level type. `"none"` disables logging; higher values are more verbose. */
4
22
  type LogLevel = (typeof LOG_LEVELS)[number];
23
+ /**
24
+ * Full scenv configuration. Built from file (scenv.config.json), environment (SCENV_*),
25
+ * and programmatic config (configure()), with programmatic > env > file precedence.
26
+ * All properties are optional; defaults apply when omitted.
27
+ */
5
28
  interface ScenvConfig {
6
- /** Replace all contexts with this list (CLI: --context a,b,c) */
29
+ /** Replace the context list entirely (CLI: `--context a,b,c`). Loaded in order; later overwrites earlier for same key. */
7
30
  contexts?: string[];
8
- /** Merge these contexts with existing (CLI: --add-context a,b,c) */
31
+ /** Merge these context names with existing (CLI: `--add-context a,b,c`). Ignored if `contexts` is set in the same layer. */
9
32
  addContexts?: string[];
10
- /** When to prompt for variable value */
33
+ /** When to prompt for a variable value. See {@link PromptMode}. Default is `"fallback"`. */
11
34
  prompt?: PromptMode;
12
- /** Ignore environment variables during resolution */
35
+ /** If true, environment variables are not used during resolution. */
13
36
  ignoreEnv?: boolean;
14
- /** Ignore loaded context during resolution */
37
+ /** If true, context files are not loaded during resolution. */
15
38
  ignoreContext?: boolean;
16
- /** Override values: key -> string (CLI: --set key=val) */
39
+ /** Override values by key (CLI: `--set key=value`). Takes precedence over env and context. */
17
40
  set?: Record<string, string>;
18
- /** When to ask "save for next time?" */
19
- savePrompt?: SavePromptMode;
20
- /** Where to save: context name or "ask" */
41
+ /** After a prompt: "never" = don't save, "always" = save without asking, "ask" = call onAskWhetherToSave (then save if true). */
42
+ shouldSavePrompt?: SavePromptMode;
43
+ /** Target context for saving: a context name, or `"ask"` to use {@link ScenvCallbacks.onAskContext}. */
21
44
  saveContextTo?: "ask" | string;
22
- /** Root directory for config/context search (default: cwd) */
45
+ /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
23
46
  root?: string;
24
- /** Log level: none (default), trace, debug, info, warn, error */
47
+ /** Logging level. Default is `"none"`. Messages go to stderr. */
25
48
  logLevel?: LogLevel;
26
49
  }
27
- /** (name, defaultValue) => value; used when a variable has no prompt option. Overridable per variable. */
50
+ /**
51
+ * Default prompt function signature. Called when a variable has no `prompt` option and
52
+ * config requests prompting. Receives the variable's display name and default value;
53
+ * returns the value to use (sync or async). Overridable per variable via the variable's
54
+ * `prompt` option or per call via get({ prompt: fn }).
55
+ */
28
56
  type DefaultPromptFn = (name: string, defaultValue: unknown) => unknown | Promise<unknown>;
57
+ /**
58
+ * Callbacks for interactive behaviour. All have built-in readline defaults when not set.
59
+ * Pass to {@link configure} via `configure({ callbacks: { ... } })`.
60
+ */
29
61
  interface ScenvCallbacks {
30
- /** Default prompt when a variable does not provide its own `prompt`. Variable's `prompt` overrides this. */
62
+ /** Used when a variable does not define its own `prompt`. Variable-level `prompt` overrides this. */
31
63
  defaultPrompt?: DefaultPromptFn;
32
- /** When user was just prompted for a value and savePrompt is ask/always: (variableName, value, contextNames) => context name to save to, or null to skip */
33
- onAskSaveAfterPrompt?: (name: string, value: unknown, contextNames: string[]) => Promise<string | null>;
34
- /** When saveContextTo is "ask": (variableName, contextNames) => context name to save to */
64
+ /** Called only when {@link ScenvConfig.shouldSavePrompt} is "ask" and the user was just prompted. Return true to save, false to skip. (With "always", we save without calling this.) */
65
+ onAskWhetherToSave?: (name: string, value: unknown) => Promise<boolean>;
66
+ /** Called when the save destination is ambiguous: saveContextTo is "ask", or after a prompt when the user said yes and saveContextTo is "ask". Return the context name to write to. */
35
67
  onAskContext?: (name: string, contextNames: string[]) => Promise<string>;
36
68
  }
69
+ /**
70
+ * Returns the current callbacks (with built-in defaults merged in for any unset callback).
71
+ * Useful for inspection or to pass a subset to another layer. Each call returns a new object.
72
+ *
73
+ * @returns Copy of the effective callbacks: defaultPrompt, onAskWhetherToSave, onAskContext (always defined).
74
+ */
37
75
  declare function getCallbacks(): ScenvCallbacks;
38
76
  /**
39
- * Load full config: file (from root or cwd) <- env <- programmatic. Precedence: programmatic > env > file.
77
+ * Loads the full merged configuration. Searches for scenv.config.json upward from the given
78
+ * root (or cwd / programmatic root), then overlays SCENV_* env vars, then programmatic config.
79
+ * Use this to read the effective config (e.g. for logging or conditional logic).
80
+ *
81
+ * @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.
40
83
  */
41
84
  declare function loadConfig(root?: string): ScenvConfig;
42
85
  /**
43
- * Set programmatic config (e.g. from CLI flags). Merged on top of env and file in loadConfig().
86
+ * Merges config and/or callbacks into the programmatic layer. Programmatic config has
87
+ * highest precedence in {@link loadConfig}. Call multiple times to merge; later values
88
+ * overwrite earlier for the same key. Typical use: pass the result of {@link parseScenvArgs}
89
+ * or your own partial config.
90
+ *
91
+ * @param partial - Partial config and/or `callbacks`. Omitted keys are left unchanged. Nested objects (e.g. callbacks, set) are merged shallowly with existing.
44
92
  */
45
93
  declare function configure(partial: Partial<ScenvConfig> & {
46
94
  callbacks?: ScenvCallbacks;
47
95
  }): void;
48
96
  /**
49
- * Reset programmatic config (mainly for tests).
97
+ * Clears all programmatic config and callbacks. File and env config are unaffected.
98
+ * Mainly for tests. After calling, the next loadConfig() will not include any programmatic overrides.
50
99
  */
51
100
  declare function resetConfig(): void;
52
101
 
53
- /** Reset internal log state (e.g. config-loaded guard). Useful in tests after resetConfig(). */
102
+ /**
103
+ * Resets internal log state (e.g. the "config loaded" one-time guard). Call after
104
+ * {@link resetConfig} in tests if you need to see config-loaded messages again or
105
+ * assert on log output.
106
+ */
54
107
  declare function resetLogState(): void;
55
108
 
56
109
  /**
57
- * Recursively find all *.context.json files under dir. Returns map: contextName -> absolute path (first found wins).
110
+ * Recursively discovers all `*.context.json` files under a directory. Context name is the
111
+ * filename without the suffix (e.g. `dev.context.json` → "dev"). First file found for a
112
+ * given name wins. Used internally for loading and saving; you can call it to inspect
113
+ * available contexts.
114
+ *
115
+ * @param dir - Root directory to search (e.g. config.root or process.cwd()).
116
+ * @param found - Optional existing map to merge results into. If omitted, a new Map is used.
117
+ * @returns Map from context name to absolute file path.
58
118
  */
59
119
  declare function discoverContextPaths(dir: string, found?: Map<string, string>): Map<string, string>;
60
120
  /**
61
- * Load context files in the order of config.contexts; merge into one flat map (later context overwrites earlier for same key).
121
+ * 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
123
+ * under the config root.
124
+ *
125
+ * @param contextName - Name of the context (e.g. "prod", "dev") — file is contextName.context.json.
126
+ * @param root - Optional root directory to search. If omitted, uses loadConfig().root.
127
+ * @returns A flat record of key → string value from that context file. Empty if file not found or invalid.
128
+ */
129
+ declare function getContext(contextName: string, root?: string): Record<string, string>;
130
+ /**
131
+ * Loads and merges context values from the current config. Respects {@link ScenvConfig.contexts}
132
+ * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
133
+ * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
134
+ * resolution (set > env > context > default).
135
+ *
136
+ * @returns A flat record of key → string value. Empty if ignoreContext is true or no contexts loaded.
62
137
  */
63
- declare function getContextValues(): Record<string, string>;
138
+ declare function getMergedContextValues(): Record<string, string>;
64
139
 
140
+ /**
141
+ * Return type for a variable's optional `validator` function. Use a boolean for simple
142
+ * pass/fail, or an object to pass a transformed value or a custom error.
143
+ * - `true` or `{ success: true, data?: T }` – validation passed; optional `data` replaces the value.
144
+ * - `false` or `{ success: false, error?: unknown }` – validation failed; `.get()` throws with the error.
145
+ */
65
146
  type ValidatorResult<T> = boolean | {
66
147
  success: true;
67
148
  data?: T;
@@ -69,23 +150,51 @@ type ValidatorResult<T> = boolean | {
69
150
  success: false;
70
151
  error?: unknown;
71
152
  };
153
+ /**
154
+ * Prompt function signature. Called when config requests prompting for this variable.
155
+ * Receives the variable's display name and the current default (from set/env/context or option default).
156
+ * Return the value to use (sync or async). Used as the variable's `prompt` option or in get({ prompt: fn }).
157
+ */
72
158
  type PromptFn<T> = (name: string, defaultValue: T) => T | Promise<T>;
159
+ /**
160
+ * Options when creating a variable with {@link scenv}. All properties are optional.
161
+ * If you omit `key` and `env`, they are derived from `name`: e.g. "API URL" → key `api_url`, env `API_URL`.
162
+ */
73
163
  interface ScenvVariableOptions<T> {
164
+ /** Internal key for --set, context files, and env. Default: name lowercased, spaces → underscores, non-alphanumeric stripped (e.g. "API URL" → "api_url"). */
74
165
  key?: string;
166
+ /** Environment variable name (e.g. API_URL). Default: key uppercased, hyphens → underscores. */
75
167
  env?: string;
168
+ /** Fallback when nothing is provided via --set, env, or context (and we're not prompting). */
76
169
  default?: T;
170
+ /** Optional. Run after value is resolved or prompted. Return true / { success: true } to accept, false / { success: false, error } to reject (get() throws). Use to coerce types or enforce rules. */
77
171
  validator?: (val: T) => ValidatorResult<T>;
172
+ /** Optional. Called when config says to prompt (e.g. prompt: "fallback" and no value found). Overrides callbacks.defaultPrompt for this variable. */
78
173
  prompt?: PromptFn<T>;
79
174
  }
80
- /** Overrides for a single .get() or .safeGet() call. */
175
+ /**
176
+ * Overrides for a single get() or safeGet() call. Only that call is affected.
177
+ */
81
178
  interface GetOptions<T> {
82
- /** Use this prompt for this call instead of the variable's prompt or callbacks.defaultPrompt. */
179
+ /** Use this prompt for this call only (e.g. a one-off inquirer prompt). */
83
180
  prompt?: PromptFn<T>;
84
- /** Use this as the default for this call if no value from set/env/context. */
181
+ /** Use this as the default for this call when no value from set/env/context. */
85
182
  default?: T;
86
183
  }
184
+ /**
185
+ * A scenv variable: a named setting (e.g. "API URL") whose value is resolved from CLI overrides (--set),
186
+ * then environment variables, then context files, then default (or an optional prompt). You call get() or
187
+ * safeGet() to read the value; optionally save() to persist it to a context file for next time.
188
+ */
87
189
  interface ScenvVariable<T> {
190
+ /**
191
+ * Resolve and return the value. Uses resolution order (set > env > context > default) and any prompt/validator.
192
+ * @throws If no value is found and no default/prompt, or if validation fails.
193
+ */
88
194
  get(options?: GetOptions<T>): Promise<T>;
195
+ /**
196
+ * Like get(), but never throws. Returns { success: true, value } or { success: false, error }.
197
+ */
89
198
  safeGet(options?: GetOptions<T>): Promise<{
90
199
  success: true;
91
200
  value: T;
@@ -93,15 +202,71 @@ interface ScenvVariable<T> {
93
202
  success: false;
94
203
  error?: unknown;
95
204
  }>;
205
+ /**
206
+ * Write the value to a context file (e.g. for next run). Target context comes from config.saveContextTo or onAskContext.
207
+ * If you don't pass a value, the last resolved value is used. Does not prompt "save?"; it saves.
208
+ */
96
209
  save(value?: T): Promise<void>;
97
210
  }
211
+ /**
212
+ * Creates a scenv variable: a named config value (e.g. API URL, port) that you read with get() or safeGet()
213
+ * and optionally write with save(). You pass a display name and optional options; key and env are derived from
214
+ * the name if you omit them.
215
+ *
216
+ * ## Resolution (how get() gets a value)
217
+ *
218
+ * get() first looks for a raw value in this order, stopping at the first found:
219
+ * - Set overrides from config (e.g. from --set key=value or configure({ set: { key: "value" } }))
220
+ * - 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)
222
+ *
223
+ * If config says to prompt (see prompt mode in config: "always", "fallback", "no-env"), the prompt callback
224
+ * may run. When it runs, it receives the variable name and a suggested value (the raw value if any, otherwise
225
+ * the default option). The callback's return value is used as the value. When we don't prompt, we use the
226
+ * raw value if present, otherwise the default option, otherwise get() throws (no value).
227
+ *
228
+ * ## Validator
229
+ *
230
+ * If you pass a validator option, it is called with the resolved or prompted value. It can return true or
231
+ * { success: true } to accept, or false or { success: false, error } to reject; on reject, get() throws.
232
+ * Use it to coerce types (e.g. string to number) or enforce rules.
233
+ *
234
+ * ## save()
235
+ *
236
+ * The variable has a save(value?) method. It writes the value (or the last resolved value if you omit it)
237
+ * to a context file. The target context comes from config.saveContextTo or from the onAskContext callback
238
+ * when saveContextTo is "ask". save() does not ask "save?"; it saves. Optional "save after prompt" behavior
239
+ * is controlled by config.shouldSavePrompt and callbacks.onAskWhetherToSave.
240
+ *
241
+ * @typeParam T - Value type (default string). Use a validator to coerce to number, boolean, etc.
242
+ * @param name - Display name used in prompts and errors. If you omit key/env, key is derived from name (e.g. "API URL" → "api_url") and env from key (e.g. "API_URL").
243
+ * @param options - Optional. key, env, default, validator, prompt. See {@link ScenvVariableOptions}.
244
+ * @returns A {@link ScenvVariable} with get(), safeGet(), and save().
245
+ *
246
+ * @example
247
+ * const apiUrl = scenv("API URL", { default: "http://localhost:4000" });
248
+ * const url = await apiUrl.get();
249
+ */
98
250
  declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): ScenvVariable<T>;
99
251
 
100
252
  /**
101
- * Parse argv (e.g. process.argv.slice(2)) into ScenvConfig for configure().
102
- * Supports: --context a,b,c --add-context x,y --prompt fallback --ignore-env --ignore-context
103
- * --set key=value --save-prompt ask --save-context-to prod --log-level trace
253
+ * Parses command-line arguments into a partial {@link ScenvConfig} suitable for {@link configure}.
254
+ * Typical use: `configure(parseScenvArgs(process.argv.slice(2)))`. Unrecognized flags are ignored.
255
+ *
256
+ * Supported flags:
257
+ * - `--context a,b,c` – Set contexts (replace).
258
+ * - `--add-context x,y` – Add contexts.
259
+ * - `--prompt always|never|fallback|no-env` – Prompt mode.
260
+ * - `--ignore-env` – Set ignoreEnv to true.
261
+ * - `--ignore-context` – Set ignoreContext to true.
262
+ * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
263
+ * - `--save-prompt always|never|ask` – shouldSavePrompt.
264
+ * - `--save-context-to name` – saveContextTo.
265
+ * - `--log-level level`, `--log level`, `--log=level` – logLevel.
266
+ *
267
+ * @param argv - Array of CLI arguments (e.g. process.argv.slice(2)).
268
+ * @returns Partial ScenvConfig with only the keys that were present in argv.
104
269
  */
105
270
  declare function parseScenvArgs(argv: string[]): Partial<ScenvConfig>;
106
271
 
107
- export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type SavePromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContextValues, loadConfig, parseScenvArgs, resetConfig, resetLogState, scenv };
272
+ export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type SavePromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContext, getMergedContextValues, loadConfig, parseScenvArgs, resetConfig, resetLogState, scenv };
package/dist/index.js CHANGED
@@ -1,6 +1,52 @@
1
1
  // src/config.ts
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { dirname, join } from "path";
4
+
5
+ // src/prompt-default.ts
6
+ import { createInterface } from "readline";
7
+ function ask(message) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ return new Promise((resolve, reject) => {
10
+ rl.question(message, (answer) => {
11
+ rl.close();
12
+ resolve(answer.trim());
13
+ });
14
+ rl.on("error", (err) => {
15
+ rl.close();
16
+ reject(err);
17
+ });
18
+ });
19
+ }
20
+ function defaultPrompt(name, defaultValue) {
21
+ const defaultStr = defaultValue !== void 0 && defaultValue !== null ? String(defaultValue) : "";
22
+ const message = defaultStr ? `Enter ${name} [${defaultStr}]: ` : `Enter ${name}: `;
23
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
24
+ return new Promise((resolve, reject) => {
25
+ rl.question(message, (answer) => {
26
+ rl.close();
27
+ const trimmed = answer.trim();
28
+ const value = trimmed !== "" ? trimmed : defaultStr;
29
+ resolve(value);
30
+ });
31
+ rl.on("error", (err) => {
32
+ rl.close();
33
+ reject(err);
34
+ });
35
+ });
36
+ }
37
+ async function defaultAskWhetherToSave(name, _value) {
38
+ const answer = await ask(`Save "${name}" for next time? (y/n): `);
39
+ const v = answer.toLowerCase();
40
+ return v === "y" || v === "yes" || v === "1" || v === "true";
41
+ }
42
+ async function defaultAskContext(name, contextNames) {
43
+ const hint = contextNames.length > 0 ? ` (${contextNames.join(", ")})` : "";
44
+ const answer = await ask(`Save "${name}" to which context?${hint}: `);
45
+ if (answer) return answer;
46
+ return contextNames[0] ?? "default";
47
+ }
48
+
49
+ // src/config.ts
4
50
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
5
51
  var CONFIG_FILENAME = "scenv.config.json";
6
52
  var envKeyMap = {
@@ -9,14 +55,18 @@ var envKeyMap = {
9
55
  SCENV_PROMPT: "prompt",
10
56
  SCENV_IGNORE_ENV: "ignoreEnv",
11
57
  SCENV_IGNORE_CONTEXT: "ignoreContext",
12
- SCENV_SAVE_PROMPT: "savePrompt",
58
+ SCENV_SAVE_PROMPT: "shouldSavePrompt",
13
59
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
14
60
  SCENV_LOG_LEVEL: "logLevel"
15
61
  };
16
62
  var programmaticConfig = {};
17
63
  var programmaticCallbacks = {};
18
64
  function getCallbacks() {
19
- return { ...programmaticCallbacks };
65
+ return {
66
+ defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt,
67
+ onAskWhetherToSave: programmaticCallbacks.onAskWhetherToSave ?? defaultAskWhetherToSave,
68
+ onAskContext: programmaticCallbacks.onAskContext ?? defaultAskContext
69
+ };
20
70
  }
21
71
  function findConfigDir(startDir) {
22
72
  let dir = startDir;
@@ -38,11 +88,11 @@ function configFromEnv() {
38
88
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
39
89
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
40
90
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
41
- } else if (configKey === "prompt" || configKey === "savePrompt") {
91
+ } else if (configKey === "prompt" || configKey === "shouldSavePrompt") {
42
92
  const v = val.toLowerCase();
43
93
  if (configKey === "prompt" && (v === "always" || v === "never" || v === "fallback" || v === "no-env"))
44
94
  out[configKey] = v;
45
- if (configKey === "savePrompt" && (v === "always" || v === "never" || v === "ask"))
95
+ if (configKey === "shouldSavePrompt" && (v === "always" || v === "never" || v === "ask"))
46
96
  out[configKey] = v;
47
97
  } else if (configKey === "saveContextTo") {
48
98
  out.saveContextTo = val;
@@ -75,8 +125,8 @@ function loadConfigFile(configDir) {
75
125
  out.ignoreContext = parsed.ignoreContext;
76
126
  if (parsed.set && typeof parsed.set === "object" && !Array.isArray(parsed.set))
77
127
  out.set = parsed.set;
78
- if (typeof parsed.savePrompt === "string" && ["always", "never", "ask"].includes(parsed.savePrompt))
79
- out.savePrompt = parsed.savePrompt;
128
+ if (typeof (parsed.shouldSavePrompt ?? parsed.savePrompt) === "string" && ["always", "never", "ask"].includes(parsed.shouldSavePrompt ?? parsed.savePrompt))
129
+ out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
80
130
  if (typeof parsed.saveContextTo === "string")
81
131
  out.saveContextTo = parsed.saveContextTo;
82
132
  if (typeof parsed.root === "string") out.root = parsed.root;
@@ -226,7 +276,32 @@ function discoverContextPaths(dir, found = /* @__PURE__ */ new Map()) {
226
276
  );
227
277
  return found;
228
278
  }
229
- function getContextValues() {
279
+ function getContext(contextName, root) {
280
+ const config = loadConfig();
281
+ const searchRoot = root ?? config.root ?? process.cwd();
282
+ const paths = discoverContextPaths(searchRoot);
283
+ const filePath = paths.get(contextName);
284
+ if (!filePath) {
285
+ log("trace", `getContext: context "${contextName}" not found`);
286
+ return {};
287
+ }
288
+ try {
289
+ const raw = readFileSync2(filePath, "utf-8");
290
+ const data = JSON.parse(raw);
291
+ const out = {};
292
+ for (const [k, v] of Object.entries(data)) {
293
+ if (typeof v === "string") out[k] = v;
294
+ }
295
+ return out;
296
+ } catch (err) {
297
+ log(
298
+ "trace",
299
+ `getContext: "${contextName}" unreadable: ${err instanceof Error ? err.message : String(err)}`
300
+ );
301
+ return {};
302
+ }
303
+ }
304
+ function getMergedContextValues() {
230
305
  const config = loadConfig();
231
306
  logConfigLoaded(config);
232
307
  if (config.ignoreContext) return {};
@@ -283,6 +358,27 @@ function writeToContext(contextName, key, value) {
283
358
  }
284
359
 
285
360
  // src/variable.ts
361
+ var CONTEXT_REF_REGEX = /^@([^:]+):(.+)$/;
362
+ var MAX_CONTEXT_REF_DEPTH = 10;
363
+ function resolveContextReference(raw, depth = 0) {
364
+ if (depth >= MAX_CONTEXT_REF_DEPTH) {
365
+ throw new Error(
366
+ `Context reference resolution exceeded max depth (${MAX_CONTEXT_REF_DEPTH}): possible circular reference`
367
+ );
368
+ }
369
+ const match = raw.match(CONTEXT_REF_REGEX);
370
+ if (!match) return raw;
371
+ const [, contextName, refKey] = match;
372
+ const ctx = getContext(contextName);
373
+ const resolved = ctx[refKey];
374
+ if (resolved === void 0) {
375
+ 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).`;
377
+ log("error", msg);
378
+ throw new Error(msg);
379
+ }
380
+ return resolveContextReference(resolved, depth + 1);
381
+ }
286
382
  function defaultKeyFromName(name) {
287
383
  return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/gi, "");
288
384
  }
@@ -307,22 +403,25 @@ function scenv(name, options = {}) {
307
403
  log("trace", `resolveRaw: checking set for key=${key}`);
308
404
  if (config.set?.[key] !== void 0) {
309
405
  log("trace", `resolveRaw: set hit key=${key}`);
310
- return { raw: config.set[key], source: "set" };
406
+ const raw = resolveContextReference(config.set[key]);
407
+ return { raw, source: "set" };
311
408
  }
312
409
  if (!config.ignoreEnv) {
313
410
  log("trace", `resolveRaw: checking env ${envKey}`);
314
411
  const envVal = process.env[envKey];
315
412
  if (envVal !== void 0 && envVal !== "") {
316
413
  log("trace", "resolveRaw: env hit");
317
- return { raw: envVal, source: "env" };
414
+ const raw = resolveContextReference(envVal);
415
+ return { raw, source: "env" };
318
416
  }
319
417
  }
320
418
  if (!config.ignoreContext) {
321
419
  log("trace", "resolveRaw: checking context");
322
- const ctx = getContextValues();
420
+ const ctx = getMergedContextValues();
323
421
  if (ctx[key] !== void 0) {
324
422
  log("trace", `resolveRaw: context hit key=${key}`);
325
- return { raw: ctx[key], source: "context" };
423
+ const raw = resolveContextReference(ctx[key]);
424
+ return { raw, source: "context" };
326
425
  }
327
426
  }
328
427
  log("trace", "resolveRaw: no value");
@@ -348,6 +447,7 @@ function scenv(name, options = {}) {
348
447
  `prompt decision key=${key} prompt=${config.prompt ?? "fallback"} hadValue=${hadValue} hadEnv=${hadEnv} -> ${doPrompt ? "prompt" : "no prompt"}`
349
448
  );
350
449
  const effectiveDefault = overrides?.default !== void 0 ? overrides.default : defaultValue;
450
+ const resolvedDefault = effectiveDefault === void 0 ? void 0 : typeof effectiveDefault === "string" ? resolveContextReference(effectiveDefault) : effectiveDefault;
351
451
  let wasPrompted = false;
352
452
  let value;
353
453
  let resolvedFrom;
@@ -359,15 +459,16 @@ function scenv(name, options = {}) {
359
459
  `Prompt required for variable "${name}" (key: ${key}) but no prompt was supplied and no defaultPrompt callback is configured. Set a prompt on the variable or configure({ callbacks: { defaultPrompt: ... } }).`
360
460
  );
361
461
  }
362
- const defaultForPrompt = raw !== void 0 ? raw : effectiveDefault;
363
- value = await Promise.resolve(fn(name, defaultForPrompt));
462
+ const defaultForPrompt = raw !== void 0 ? raw : resolvedDefault;
463
+ let promptedValue = await Promise.resolve(fn(name, defaultForPrompt));
464
+ value = typeof promptedValue === "string" ? resolveContextReference(promptedValue) : promptedValue;
364
465
  wasPrompted = true;
365
466
  resolvedFrom = "prompt";
366
467
  } else if (raw !== void 0) {
367
468
  value = raw;
368
469
  resolvedFrom = source;
369
- } else if (effectiveDefault !== void 0) {
370
- value = effectiveDefault;
470
+ } else if (resolvedDefault !== void 0) {
471
+ value = resolvedDefault;
371
472
  resolvedFrom = "default";
372
473
  } else {
373
474
  throw new Error(`Missing value for variable "${name}" (key: ${key})`);
@@ -399,25 +500,36 @@ function scenv(name, options = {}) {
399
500
  const final = validated.data;
400
501
  if (wasPrompted) {
401
502
  const config = loadConfig();
402
- const savePrompt = config.savePrompt ?? (config.prompt === "never" ? "never" : "ask");
403
- const shouldAskSave = savePrompt === "always" || savePrompt === "ask" && wasPrompted;
404
- if (shouldAskSave) {
405
- const callbacks = getCallbacks();
406
- if (typeof callbacks.onAskSaveAfterPrompt !== "function") {
503
+ const mode = config.shouldSavePrompt ?? (config.prompt === "never" ? "never" : "ask");
504
+ if (mode === "never") return final;
505
+ let doSave;
506
+ if (mode === "ask") {
507
+ const callbacks2 = getCallbacks();
508
+ if (typeof callbacks2.onAskWhetherToSave !== "function") {
407
509
  throw new Error(
408
- `savePrompt is "${savePrompt}" but onAskSaveAfterPrompt callback is not set. Configure callbacks via configure({ callbacks: { onAskSaveAfterPrompt: ... } }).`
510
+ `shouldSavePrompt is "ask" but onAskWhetherToSave callback is not set. Configure callbacks via configure({ callbacks: { onAskWhetherToSave: ... } }).`
409
511
  );
410
512
  }
411
- const ctxToSave = await callbacks.onAskSaveAfterPrompt(
412
- name,
413
- final,
414
- config.contexts ?? []
415
- );
416
- if (ctxToSave) {
417
- writeToContext(ctxToSave, key, String(final));
418
- log("info", `Saved key=${key} to context ${ctxToSave}`);
513
+ doSave = await callbacks2.onAskWhetherToSave(name, final);
514
+ } else {
515
+ doSave = true;
516
+ }
517
+ if (!doSave) return final;
518
+ const callbacks = getCallbacks();
519
+ const contextNames = config.contexts ?? [];
520
+ let ctxToSave;
521
+ if (config.saveContextTo === "ask") {
522
+ if (typeof callbacks.onAskContext !== "function") {
523
+ throw new Error(
524
+ `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
525
+ );
419
526
  }
527
+ ctxToSave = await callbacks.onAskContext(name, contextNames);
528
+ } else {
529
+ ctxToSave = config.saveContextTo ?? contextNames[0] ?? "default";
420
530
  }
531
+ writeToContext(ctxToSave, key, String(final));
532
+ log("info", `Saved key=${key} to context ${ctxToSave}`);
421
533
  }
422
534
  return final;
423
535
  }
@@ -487,7 +599,7 @@ function parseScenvArgs(argv) {
487
599
  } else if (arg === "--save-prompt" && argv[i + 1] !== void 0) {
488
600
  const v = argv[++i].toLowerCase();
489
601
  if (["always", "never", "ask"].includes(v)) {
490
- config.savePrompt = v;
602
+ config.shouldSavePrompt = v;
491
603
  }
492
604
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
493
605
  config.saveContextTo = argv[++i];
@@ -518,7 +630,8 @@ export {
518
630
  configure,
519
631
  discoverContextPaths,
520
632
  getCallbacks,
521
- getContextValues,
633
+ getContext,
634
+ getMergedContextValues,
522
635
  loadConfig,
523
636
  parseScenvArgs,
524
637
  resetConfig,
package/package.json CHANGED
@@ -1,10 +1,21 @@
1
1
  {
2
2
  "name": "scenv",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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",