scenv 0.6.1 → 0.7.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/dist/index.cjs CHANGED
@@ -25,12 +25,16 @@ __export(index_exports, {
25
25
  discoverContextPaths: () => discoverContextPaths,
26
26
  getCallbacks: () => getCallbacks,
27
27
  getContext: () => getContext,
28
+ getContextWritePath: () => getContextWritePath,
29
+ getInMemoryContext: () => getInMemoryContext,
28
30
  getMergedContextValues: () => getMergedContextValues,
29
31
  loadConfig: () => loadConfig,
30
32
  parseScenvArgs: () => parseScenvArgs,
31
33
  resetConfig: () => resetConfig,
34
+ resetInMemoryContext: () => resetInMemoryContext,
32
35
  resetLogState: () => resetLogState,
33
- scenv: () => scenv
36
+ scenv: () => scenv,
37
+ setInMemoryContext: () => setInMemoryContext
34
38
  });
35
39
  module.exports = __toCommonJS(index_exports);
36
40
 
@@ -40,19 +44,6 @@ var import_node_path = require("path");
40
44
 
41
45
  // src/prompt-default.ts
42
46
  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
47
  function defaultPrompt(name, defaultValue) {
57
48
  const defaultStr = defaultValue !== void 0 && defaultValue !== null ? String(defaultValue) : "";
58
49
  const message = defaultStr ? `Enter ${name} [${defaultStr}]: ` : `Enter ${name}: `;
@@ -70,17 +61,6 @@ function defaultPrompt(name, defaultValue) {
70
61
  });
71
62
  });
72
63
  }
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
64
 
85
65
  // src/config.ts
86
66
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
@@ -91,7 +71,6 @@ var envKeyMap = {
91
71
  SCENV_PROMPT: "prompt",
92
72
  SCENV_IGNORE_ENV: "ignoreEnv",
93
73
  SCENV_IGNORE_CONTEXT: "ignoreContext",
94
- SCENV_SAVE_PROMPT: "shouldSavePrompt",
95
74
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
96
75
  SCENV_CONTEXT_DIR: "contextDir",
97
76
  SCENV_LOG_LEVEL: "logLevel"
@@ -100,9 +79,7 @@ var programmaticConfig = {};
100
79
  var programmaticCallbacks = {};
101
80
  function getCallbacks() {
102
81
  return {
103
- defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt,
104
- onAskWhetherToSave: programmaticCallbacks.onAskWhetherToSave ?? defaultAskWhetherToSave,
105
- onAskContext: programmaticCallbacks.onAskContext ?? defaultAskContext
82
+ defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt
106
83
  };
107
84
  }
108
85
  function findConfigDir(startDir) {
@@ -125,11 +102,9 @@ function configFromEnv() {
125
102
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
126
103
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
127
104
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
128
- } else if (configKey === "prompt" || configKey === "shouldSavePrompt") {
105
+ } else if (configKey === "prompt") {
129
106
  const v = val.toLowerCase();
130
- if (configKey === "prompt" && (v === "always" || v === "never" || v === "fallback" || v === "no-env"))
131
- out[configKey] = v;
132
- if (configKey === "shouldSavePrompt" && (v === "always" || v === "never" || v === "ask"))
107
+ if (v === "always" || v === "never" || v === "fallback" || v === "no-env")
133
108
  out[configKey] = v;
134
109
  } else if (configKey === "saveContextTo") {
135
110
  out.saveContextTo = val;
@@ -172,8 +147,6 @@ function loadConfigFile(configDir) {
172
147
  out.ignoreContext = parsed.ignoreContext;
173
148
  if (parsed.set && typeof parsed.set === "object" && !Array.isArray(parsed.set))
174
149
  out.set = parsed.set;
175
- if (typeof (parsed.shouldSavePrompt ?? parsed.savePrompt) === "string" && ["always", "never", "ask"].includes(parsed.shouldSavePrompt ?? parsed.savePrompt))
176
- out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
177
150
  if (typeof parsed.saveContextTo === "string")
178
151
  out.saveContextTo = parsed.saveContextTo;
179
152
  if (typeof parsed.contextDir === "string") out.contextDir = parsed.contextDir;
@@ -264,7 +237,6 @@ var CONFIG_LOG_KEYS = [
264
237
  "ignoreEnv",
265
238
  "ignoreContext",
266
239
  "set",
267
- "shouldSavePrompt",
268
240
  "saveContextTo",
269
241
  "contextDir",
270
242
  "logLevel"
@@ -288,7 +260,6 @@ function logConfigLoaded(config) {
288
260
  if (config.ignoreContext === true) parts.push("ignoreContext=true");
289
261
  if (config.saveContextTo !== void 0) parts.push("saveContextTo=" + config.saveContextTo);
290
262
  if (config.contextDir !== void 0) parts.push("contextDir=" + config.contextDir);
291
- if (config.shouldSavePrompt !== void 0) parts.push("shouldSavePrompt=" + config.shouldSavePrompt);
292
263
  log("info", "config loaded", parts.join(" "));
293
264
  }
294
265
  if (levelNum >= LEVEL_NUM.debug) {
@@ -311,6 +282,16 @@ function log(level, msg, ...args) {
311
282
  var import_node_fs2 = require("fs");
312
283
  var import_node_path2 = require("path");
313
284
  var CONTEXT_SUFFIX = ".context.json";
285
+ var inMemoryContext = {};
286
+ function getInMemoryContext() {
287
+ return inMemoryContext;
288
+ }
289
+ function setInMemoryContext(key, value) {
290
+ inMemoryContext[key] = value;
291
+ }
292
+ function resetInMemoryContext() {
293
+ inMemoryContext = {};
294
+ }
314
295
  function discoverContextPathsInternal(dir, found) {
315
296
  let entries;
316
297
  try {
@@ -378,6 +359,14 @@ function getMergedContextValues() {
378
359
  const root = config.root ?? process.cwd();
379
360
  const paths = discoverContextPaths(root);
380
361
  const out = {};
362
+ if (config.saveContextTo) {
363
+ const savePath = resolveSaveContextPath(config.saveContextTo);
364
+ const saveCtx = getContextAtPath(savePath);
365
+ for (const [k, v] of Object.entries(saveCtx)) out[k] = v;
366
+ if (Object.keys(saveCtx).length > 0) {
367
+ log("debug", `saveContextTo "${config.saveContextTo}" loaded keys=${JSON.stringify(Object.keys(saveCtx))}`);
368
+ }
369
+ }
381
370
  for (const contextName of config.context ?? []) {
382
371
  const filePath = paths.get(contextName);
383
372
  if (!filePath) {
@@ -405,6 +394,9 @@ function getMergedContextValues() {
405
394
  return out;
406
395
  }
407
396
  function getContextWritePath(contextName) {
397
+ if ((0, import_node_path2.isAbsolute)(contextName) || contextName.includes(import_node_path2.sep)) {
398
+ return contextName.endsWith(CONTEXT_SUFFIX) ? contextName : contextName + CONTEXT_SUFFIX;
399
+ }
408
400
  const config = loadConfig();
409
401
  const root = config.root ?? process.cwd();
410
402
  const paths = discoverContextPaths(root);
@@ -413,6 +405,26 @@ function getContextWritePath(contextName) {
413
405
  const saveDir = config.contextDir ? (0, import_node_path2.isAbsolute)(config.contextDir) ? config.contextDir : (0, import_node_path2.join)(root, config.contextDir) : root;
414
406
  return (0, import_node_path2.join)(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
415
407
  }
408
+ function resolveSaveContextPath(nameOrPath) {
409
+ if ((0, import_node_path2.isAbsolute)(nameOrPath) || nameOrPath.includes(import_node_path2.sep)) {
410
+ return nameOrPath.endsWith(CONTEXT_SUFFIX) ? nameOrPath : nameOrPath + CONTEXT_SUFFIX;
411
+ }
412
+ return getContextWritePath(nameOrPath);
413
+ }
414
+ function getContextAtPath(filePath) {
415
+ if (!(0, import_node_fs2.existsSync)(filePath)) return {};
416
+ try {
417
+ const raw = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
418
+ const data = JSON.parse(raw);
419
+ const out = {};
420
+ for (const [k, v] of Object.entries(data)) {
421
+ if (typeof v === "string") out[k] = v;
422
+ }
423
+ return out;
424
+ } catch {
425
+ return {};
426
+ }
427
+ }
416
428
  function writeToContext(contextName, key, value) {
417
429
  const path = getContextWritePath(contextName);
418
430
  let data = {};
@@ -509,6 +521,12 @@ function scenv(name, options = {}) {
509
521
  return { raw, source: "env" };
510
522
  }
511
523
  }
524
+ const mem = getInMemoryContext();
525
+ if (mem[key] !== void 0) {
526
+ log("trace", `resolveRaw: in-memory hit key=${key}`);
527
+ const raw = resolveContextReference(mem[key], key);
528
+ return { raw, source: "context" };
529
+ }
512
530
  if (!config.ignoreContext) {
513
531
  log("trace", "resolveRaw: checking context");
514
532
  const ctx = getMergedContextValues();
@@ -594,36 +612,11 @@ function scenv(name, options = {}) {
594
612
  const final = validated.data;
595
613
  if (wasPrompted) {
596
614
  const config = loadConfig();
597
- const mode = config.shouldSavePrompt ?? (config.prompt === "never" ? "never" : "ask");
598
- if (mode === "never") return final;
599
- let doSave;
600
- if (mode === "ask") {
601
- const callbacks2 = getCallbacks();
602
- if (typeof callbacks2.onAskWhetherToSave !== "function") {
603
- throw new Error(
604
- `shouldSavePrompt is "ask" but onAskWhetherToSave callback is not set. Configure callbacks via configure({ callbacks: { onAskWhetherToSave: ... } }).`
605
- );
606
- }
607
- doSave = await callbacks2.onAskWhetherToSave(name, final);
608
- } else {
609
- doSave = true;
610
- }
611
- if (!doSave) return final;
612
- const callbacks = getCallbacks();
613
- const contextNames = config.context ?? [];
614
- let ctxToSave;
615
- if (config.saveContextTo === "ask") {
616
- if (typeof callbacks.onAskContext !== "function") {
617
- throw new Error(
618
- `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
619
- );
620
- }
621
- ctxToSave = await callbacks.onAskContext(name, contextNames);
622
- } else {
623
- ctxToSave = config.saveContextTo ?? contextNames[0] ?? "default";
615
+ setInMemoryContext(key, String(final));
616
+ if (config.saveContextTo) {
617
+ writeToContext(config.saveContextTo, key, String(final));
618
+ log("info", `Saved key=${key} to saveContextTo ${config.saveContextTo}`);
624
619
  }
625
- writeToContext(ctxToSave, key, String(final));
626
- log("info", `Saved key=${key} to context ${ctxToSave}`);
627
620
  }
628
621
  return final;
629
622
  }
@@ -644,22 +637,11 @@ function scenv(name, options = {}) {
644
637
  throw new Error(errMsg);
645
638
  }
646
639
  const config = loadConfig();
647
- let contextName = config.saveContextTo;
648
- if (contextName === "ask") {
649
- const callbacks = getCallbacks();
650
- if (typeof callbacks.onAskContext !== "function") {
651
- throw new Error(
652
- `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
653
- );
654
- }
655
- contextName = await callbacks.onAskContext(
656
- name,
657
- config.context ?? []
658
- );
640
+ setInMemoryContext(key, String(validated.data));
641
+ if (config.saveContextTo) {
642
+ writeToContext(config.saveContextTo, key, String(validated.data));
643
+ log("info", `Saved key=${key} to saveContextTo ${config.saveContextTo}`);
659
644
  }
660
- if (!contextName) contextName = config.context?.[0] ?? "default";
661
- writeToContext(contextName, key, String(validated.data));
662
- log("info", `Saved key=${key} to context ${contextName}`);
663
645
  }
664
646
  return { get, safeGet, save };
665
647
  }
@@ -690,11 +672,6 @@ function parseScenvArgs(argv) {
690
672
  config.set = config.set ?? {};
691
673
  config.set[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
692
674
  }
693
- } else if (arg === "--save-prompt" && argv[i + 1] !== void 0) {
694
- const v = argv[++i].toLowerCase();
695
- if (["always", "never", "ask"].includes(v)) {
696
- config.shouldSavePrompt = v;
697
- }
698
675
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
699
676
  config.saveContextTo = argv[++i];
700
677
  } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
@@ -728,10 +705,14 @@ function parseScenvArgs(argv) {
728
705
  discoverContextPaths,
729
706
  getCallbacks,
730
707
  getContext,
708
+ getContextWritePath,
709
+ getInMemoryContext,
731
710
  getMergedContextValues,
732
711
  loadConfig,
733
712
  parseScenvArgs,
734
713
  resetConfig,
714
+ resetInMemoryContext,
735
715
  resetLogState,
736
- scenv
716
+ scenv,
717
+ setInMemoryContext
737
718
  });
package/dist/index.d.cts CHANGED
@@ -6,13 +6,6 @@
6
6
  * - `"no-env"` – Prompt when the env var is not set (even if context has a value).
7
7
  */
8
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
- */
15
- type SavePromptMode = "always" | "never" | "ask";
16
9
  /**
17
10
  * Valid log levels. Use with {@link ScenvConfig.logLevel}.
18
11
  * Messages at or above the configured level are written to stderr.
@@ -38,10 +31,8 @@ interface ScenvConfig {
38
31
  ignoreContext?: boolean;
39
32
  /** Override values by key (CLI: `--set key=value`). Takes precedence over env and context. */
40
33
  set?: Record<string, string>;
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}. */
44
- saveContextTo?: "ask" | string;
34
+ /** Optional path or context name (without .context.json) where to save resolved values. If set, all saves go here and this context is used before prompting. If unset, values are saved to an in-memory context only (same process). */
35
+ saveContextTo?: string;
45
36
  /** 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
37
  contextDir?: string;
47
38
  /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
@@ -57,22 +48,16 @@ interface ScenvConfig {
57
48
  */
58
49
  type DefaultPromptFn = (name: string, defaultValue: unknown) => unknown | Promise<unknown>;
59
50
  /**
60
- * Callbacks for interactive behaviour. All have built-in readline defaults when not set.
61
- * Pass to {@link configure} via `configure({ callbacks: { ... } })`.
51
+ * Callbacks for interactive behaviour. Pass to {@link configure} via `configure({ callbacks: { ... } })`.
62
52
  */
63
53
  interface ScenvCallbacks {
64
54
  /** Used when a variable does not define its own `prompt`. Variable-level `prompt` overrides this. */
65
55
  defaultPrompt?: DefaultPromptFn;
66
- /** 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.) */
67
- onAskWhetherToSave?: (name: string, value: unknown) => Promise<boolean>;
68
- /** 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. */
69
- onAskContext?: (name: string, contextNames: string[]) => Promise<string>;
70
56
  }
71
57
  /**
72
- * Returns the current callbacks (with built-in defaults merged in for any unset callback).
73
- * Useful for inspection or to pass a subset to another layer. Each call returns a new object.
58
+ * Returns the current callbacks (with built-in default for defaultPrompt when not set).
74
59
  *
75
- * @returns Copy of the effective callbacks: defaultPrompt, onAskWhetherToSave, onAskContext (always defined).
60
+ * @returns Copy of the effective callbacks.
76
61
  */
77
62
  declare function getCallbacks(): ScenvCallbacks;
78
63
  /**
@@ -108,6 +93,19 @@ declare function resetConfig(): void;
108
93
  */
109
94
  declare function resetLogState(): void;
110
95
 
96
+ /**
97
+ * Returns the current in-memory context (key → value). Used during resolution before file contexts.
98
+ * Modifying the returned object mutates the store.
99
+ */
100
+ declare function getInMemoryContext(): Record<string, string>;
101
+ /**
102
+ * Sets a key-value pair in the in-memory context. Used when saving after prompt or save() when saveContextTo is unset, and always updated when a value is saved so the next get() sees it.
103
+ */
104
+ declare function setInMemoryContext(key: string, value: string): void;
105
+ /**
106
+ * Clears the in-memory context. Mainly for tests. Call in beforeEach to get a clean slate.
107
+ */
108
+ declare function resetInMemoryContext(): void;
111
109
  /**
112
110
  * Recursively discovers all `*.context.json` files under a directory. Context name is the
113
111
  * filename without the suffix (e.g. `dev.context.json` → "dev"). First file found for a
@@ -130,14 +128,23 @@ declare function discoverContextPaths(dir: string, found?: Map<string, string>):
130
128
  */
131
129
  declare function getContext(contextName: string, root?: string): Record<string, string>;
132
130
  /**
133
- * Loads and merges context values from the current config. Respects {@link ScenvConfig.context}
134
- * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
135
- * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
136
- * resolution (set > env > context > default).
131
+ * Loads and merges context values from the current config. If {@link ScenvConfig.saveContextTo}
132
+ * is set, that context (file path or name) is loaded first; then {@link ScenvConfig.context}
133
+ * order. Respects {@link ScenvConfig.ignoreContext}. Later contexts overwrite earlier for the same key.
134
+ * Used during variable resolution (set > env > in-memory > merged context > default).
137
135
  *
138
136
  * @returns A flat record of key → string value. Empty if ignoreContext is true or no context loaded.
139
137
  */
140
138
  declare function getMergedContextValues(): Record<string, string>;
139
+ /**
140
+ * Returns the file path used for a context name or path when saving.
141
+ * - If contextName is path-like (absolute or contains path separator), returns that path with .context.json appended if not already present.
142
+ * - Otherwise, if that context was already discovered under config.root, returns its path; else uses config.contextDir (if set) or root.
143
+ *
144
+ * @param contextName - Context name (e.g. "dev", "prod") or file path without suffix (e.g. "/path/to/myfile" → myfile.context.json).
145
+ * @returns Absolute path to the context JSON file.
146
+ */
147
+ declare function getContextWritePath(contextName: string): string;
141
148
 
142
149
  /**
143
150
  * Return type for a variable's optional `validator` function. Use a boolean for simple
@@ -205,8 +212,8 @@ interface ScenvVariable<T> {
205
212
  error?: unknown;
206
213
  }>;
207
214
  /**
208
- * Write the value to a context file (e.g. for next run). Target context comes from config.saveContextTo or onAskContext.
209
- * If you don't pass a value, the last resolved value is used. Does not prompt "save?"; it saves.
215
+ * Write the value to the save target: if config.saveContextTo is set, to that context file; otherwise to in-memory only.
216
+ * If you don't pass a value, the last resolved value is used. Does not prompt; it saves. The value is always stored in-memory so the next get() sees it.
210
217
  */
211
218
  save(value?: T): Promise<void>;
212
219
  }
@@ -236,9 +243,9 @@ interface ScenvVariable<T> {
236
243
  * ## save()
237
244
  *
238
245
  * The variable has a save(value?) method. It writes the value (or the last resolved value if you omit it)
239
- * to a context file. The target context comes from config.saveContextTo or from the onAskContext callback
240
- * when saveContextTo is "ask". save() does not ask "save?"; it saves. Optional "save after prompt" behavior
241
- * is controlled by config.shouldSavePrompt and callbacks.onAskWhetherToSave.
246
+ * to the save target (config.saveContextTo file if set, otherwise in-memory). save() does not ask; it saves.
247
+ * When the user is prompted during get(), the value is always saved (to saveContextTo file if set, and always to in-memory)
248
+ * so a second get() on the same variable does not prompt again.
242
249
  *
243
250
  * @typeParam T - Value type (default string). Use a validator to coerce to number, boolean, etc.
244
251
  * @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").
@@ -262,8 +269,7 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
262
269
  * - `--ignore-env` – Set ignoreEnv to true.
263
270
  * - `--ignore-context` – Set ignoreContext to true.
264
271
  * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
265
- * - `--save-prompt always|never|ask` – shouldSavePrompt.
266
- * - `--save-context-to name` – saveContextTo.
272
+ * - `--save-context-to pathOrName` – saveContextTo (path or context name without .context.json).
267
273
  * - `--context-dir path` – contextDir (directory to save context files to by default).
268
274
  * - `--log-level level`, `--log level`, `--log=level` – logLevel.
269
275
  *
@@ -272,4 +278,4 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
272
278
  */
273
279
  declare function parseScenvArgs(argv: string[]): Partial<ScenvConfig>;
274
280
 
275
- 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 };
281
+ export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContext, getContextWritePath, getInMemoryContext, getMergedContextValues, loadConfig, parseScenvArgs, resetConfig, resetInMemoryContext, resetLogState, scenv, setInMemoryContext };
package/dist/index.d.ts CHANGED
@@ -6,13 +6,6 @@
6
6
  * - `"no-env"` – Prompt when the env var is not set (even if context has a value).
7
7
  */
8
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
- */
15
- type SavePromptMode = "always" | "never" | "ask";
16
9
  /**
17
10
  * Valid log levels. Use with {@link ScenvConfig.logLevel}.
18
11
  * Messages at or above the configured level are written to stderr.
@@ -38,10 +31,8 @@ interface ScenvConfig {
38
31
  ignoreContext?: boolean;
39
32
  /** Override values by key (CLI: `--set key=value`). Takes precedence over env and context. */
40
33
  set?: Record<string, string>;
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}. */
44
- saveContextTo?: "ask" | string;
34
+ /** Optional path or context name (without .context.json) where to save resolved values. If set, all saves go here and this context is used before prompting. If unset, values are saved to an in-memory context only (same process). */
35
+ saveContextTo?: string;
45
36
  /** 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
37
  contextDir?: string;
47
38
  /** Root directory for config file search and context discovery. Default is cwd or the directory containing scenv.config.json. */
@@ -57,22 +48,16 @@ interface ScenvConfig {
57
48
  */
58
49
  type DefaultPromptFn = (name: string, defaultValue: unknown) => unknown | Promise<unknown>;
59
50
  /**
60
- * Callbacks for interactive behaviour. All have built-in readline defaults when not set.
61
- * Pass to {@link configure} via `configure({ callbacks: { ... } })`.
51
+ * Callbacks for interactive behaviour. Pass to {@link configure} via `configure({ callbacks: { ... } })`.
62
52
  */
63
53
  interface ScenvCallbacks {
64
54
  /** Used when a variable does not define its own `prompt`. Variable-level `prompt` overrides this. */
65
55
  defaultPrompt?: DefaultPromptFn;
66
- /** 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.) */
67
- onAskWhetherToSave?: (name: string, value: unknown) => Promise<boolean>;
68
- /** 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. */
69
- onAskContext?: (name: string, contextNames: string[]) => Promise<string>;
70
56
  }
71
57
  /**
72
- * Returns the current callbacks (with built-in defaults merged in for any unset callback).
73
- * Useful for inspection or to pass a subset to another layer. Each call returns a new object.
58
+ * Returns the current callbacks (with built-in default for defaultPrompt when not set).
74
59
  *
75
- * @returns Copy of the effective callbacks: defaultPrompt, onAskWhetherToSave, onAskContext (always defined).
60
+ * @returns Copy of the effective callbacks.
76
61
  */
77
62
  declare function getCallbacks(): ScenvCallbacks;
78
63
  /**
@@ -108,6 +93,19 @@ declare function resetConfig(): void;
108
93
  */
109
94
  declare function resetLogState(): void;
110
95
 
96
+ /**
97
+ * Returns the current in-memory context (key → value). Used during resolution before file contexts.
98
+ * Modifying the returned object mutates the store.
99
+ */
100
+ declare function getInMemoryContext(): Record<string, string>;
101
+ /**
102
+ * Sets a key-value pair in the in-memory context. Used when saving after prompt or save() when saveContextTo is unset, and always updated when a value is saved so the next get() sees it.
103
+ */
104
+ declare function setInMemoryContext(key: string, value: string): void;
105
+ /**
106
+ * Clears the in-memory context. Mainly for tests. Call in beforeEach to get a clean slate.
107
+ */
108
+ declare function resetInMemoryContext(): void;
111
109
  /**
112
110
  * Recursively discovers all `*.context.json` files under a directory. Context name is the
113
111
  * filename without the suffix (e.g. `dev.context.json` → "dev"). First file found for a
@@ -130,14 +128,23 @@ declare function discoverContextPaths(dir: string, found?: Map<string, string>):
130
128
  */
131
129
  declare function getContext(contextName: string, root?: string): Record<string, string>;
132
130
  /**
133
- * Loads and merges context values from the current config. Respects {@link ScenvConfig.context}
134
- * order and {@link ScenvConfig.ignoreContext}. Each context file is a JSON object of string
135
- * key-value pairs; later contexts overwrite earlier for the same key. Used during variable
136
- * resolution (set > env > context > default).
131
+ * Loads and merges context values from the current config. If {@link ScenvConfig.saveContextTo}
132
+ * is set, that context (file path or name) is loaded first; then {@link ScenvConfig.context}
133
+ * order. Respects {@link ScenvConfig.ignoreContext}. Later contexts overwrite earlier for the same key.
134
+ * Used during variable resolution (set > env > in-memory > merged context > default).
137
135
  *
138
136
  * @returns A flat record of key → string value. Empty if ignoreContext is true or no context loaded.
139
137
  */
140
138
  declare function getMergedContextValues(): Record<string, string>;
139
+ /**
140
+ * Returns the file path used for a context name or path when saving.
141
+ * - If contextName is path-like (absolute or contains path separator), returns that path with .context.json appended if not already present.
142
+ * - Otherwise, if that context was already discovered under config.root, returns its path; else uses config.contextDir (if set) or root.
143
+ *
144
+ * @param contextName - Context name (e.g. "dev", "prod") or file path without suffix (e.g. "/path/to/myfile" → myfile.context.json).
145
+ * @returns Absolute path to the context JSON file.
146
+ */
147
+ declare function getContextWritePath(contextName: string): string;
141
148
 
142
149
  /**
143
150
  * Return type for a variable's optional `validator` function. Use a boolean for simple
@@ -205,8 +212,8 @@ interface ScenvVariable<T> {
205
212
  error?: unknown;
206
213
  }>;
207
214
  /**
208
- * Write the value to a context file (e.g. for next run). Target context comes from config.saveContextTo or onAskContext.
209
- * If you don't pass a value, the last resolved value is used. Does not prompt "save?"; it saves.
215
+ * Write the value to the save target: if config.saveContextTo is set, to that context file; otherwise to in-memory only.
216
+ * If you don't pass a value, the last resolved value is used. Does not prompt; it saves. The value is always stored in-memory so the next get() sees it.
210
217
  */
211
218
  save(value?: T): Promise<void>;
212
219
  }
@@ -236,9 +243,9 @@ interface ScenvVariable<T> {
236
243
  * ## save()
237
244
  *
238
245
  * The variable has a save(value?) method. It writes the value (or the last resolved value if you omit it)
239
- * to a context file. The target context comes from config.saveContextTo or from the onAskContext callback
240
- * when saveContextTo is "ask". save() does not ask "save?"; it saves. Optional "save after prompt" behavior
241
- * is controlled by config.shouldSavePrompt and callbacks.onAskWhetherToSave.
246
+ * to the save target (config.saveContextTo file if set, otherwise in-memory). save() does not ask; it saves.
247
+ * When the user is prompted during get(), the value is always saved (to saveContextTo file if set, and always to in-memory)
248
+ * so a second get() on the same variable does not prompt again.
242
249
  *
243
250
  * @typeParam T - Value type (default string). Use a validator to coerce to number, boolean, etc.
244
251
  * @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").
@@ -262,8 +269,7 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
262
269
  * - `--ignore-env` – Set ignoreEnv to true.
263
270
  * - `--ignore-context` – Set ignoreContext to true.
264
271
  * - `--set key=value` or `--set=key=value` – Add to set overrides (multiple allowed).
265
- * - `--save-prompt always|never|ask` – shouldSavePrompt.
266
- * - `--save-context-to name` – saveContextTo.
272
+ * - `--save-context-to pathOrName` – saveContextTo (path or context name without .context.json).
267
273
  * - `--context-dir path` – contextDir (directory to save context files to by default).
268
274
  * - `--log-level level`, `--log level`, `--log=level` – logLevel.
269
275
  *
@@ -272,4 +278,4 @@ declare function scenv<T>(name: string, options?: ScenvVariableOptions<T>): Scen
272
278
  */
273
279
  declare function parseScenvArgs(argv: string[]): Partial<ScenvConfig>;
274
280
 
275
- 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 };
281
+ export { type DefaultPromptFn, type GetOptions, LOG_LEVELS, type LogLevel, type PromptMode, type ScenvCallbacks, type ScenvConfig, type ScenvVariable, configure, discoverContextPaths, getCallbacks, getContext, getContextWritePath, getInMemoryContext, getMergedContextValues, loadConfig, parseScenvArgs, resetConfig, resetInMemoryContext, resetLogState, scenv, setInMemoryContext };
package/dist/index.js CHANGED
@@ -4,19 +4,6 @@ import { dirname, join } from "path";
4
4
 
5
5
  // src/prompt-default.ts
6
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
7
  function defaultPrompt(name, defaultValue) {
21
8
  const defaultStr = defaultValue !== void 0 && defaultValue !== null ? String(defaultValue) : "";
22
9
  const message = defaultStr ? `Enter ${name} [${defaultStr}]: ` : `Enter ${name}: `;
@@ -34,17 +21,6 @@ function defaultPrompt(name, defaultValue) {
34
21
  });
35
22
  });
36
23
  }
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
24
 
49
25
  // src/config.ts
50
26
  var LOG_LEVELS = ["none", "trace", "debug", "info", "warn", "error"];
@@ -55,7 +31,6 @@ var envKeyMap = {
55
31
  SCENV_PROMPT: "prompt",
56
32
  SCENV_IGNORE_ENV: "ignoreEnv",
57
33
  SCENV_IGNORE_CONTEXT: "ignoreContext",
58
- SCENV_SAVE_PROMPT: "shouldSavePrompt",
59
34
  SCENV_SAVE_CONTEXT_TO: "saveContextTo",
60
35
  SCENV_CONTEXT_DIR: "contextDir",
61
36
  SCENV_LOG_LEVEL: "logLevel"
@@ -64,9 +39,7 @@ var programmaticConfig = {};
64
39
  var programmaticCallbacks = {};
65
40
  function getCallbacks() {
66
41
  return {
67
- defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt,
68
- onAskWhetherToSave: programmaticCallbacks.onAskWhetherToSave ?? defaultAskWhetherToSave,
69
- onAskContext: programmaticCallbacks.onAskContext ?? defaultAskContext
42
+ defaultPrompt: programmaticCallbacks.defaultPrompt ?? defaultPrompt
70
43
  };
71
44
  }
72
45
  function findConfigDir(startDir) {
@@ -89,11 +62,9 @@ function configFromEnv() {
89
62
  out[configKey] = val.split(",").map((s) => s.trim()).filter(Boolean);
90
63
  } else if (configKey === "ignoreEnv" || configKey === "ignoreContext") {
91
64
  out[configKey] = val === "1" || val === "true" || val.toLowerCase() === "yes";
92
- } else if (configKey === "prompt" || configKey === "shouldSavePrompt") {
65
+ } else if (configKey === "prompt") {
93
66
  const v = val.toLowerCase();
94
- if (configKey === "prompt" && (v === "always" || v === "never" || v === "fallback" || v === "no-env"))
95
- out[configKey] = v;
96
- if (configKey === "shouldSavePrompt" && (v === "always" || v === "never" || v === "ask"))
67
+ if (v === "always" || v === "never" || v === "fallback" || v === "no-env")
97
68
  out[configKey] = v;
98
69
  } else if (configKey === "saveContextTo") {
99
70
  out.saveContextTo = val;
@@ -136,8 +107,6 @@ function loadConfigFile(configDir) {
136
107
  out.ignoreContext = parsed.ignoreContext;
137
108
  if (parsed.set && typeof parsed.set === "object" && !Array.isArray(parsed.set))
138
109
  out.set = parsed.set;
139
- if (typeof (parsed.shouldSavePrompt ?? parsed.savePrompt) === "string" && ["always", "never", "ask"].includes(parsed.shouldSavePrompt ?? parsed.savePrompt))
140
- out.shouldSavePrompt = parsed.shouldSavePrompt ?? parsed.savePrompt;
141
110
  if (typeof parsed.saveContextTo === "string")
142
111
  out.saveContextTo = parsed.saveContextTo;
143
112
  if (typeof parsed.contextDir === "string") out.contextDir = parsed.contextDir;
@@ -228,7 +197,6 @@ var CONFIG_LOG_KEYS = [
228
197
  "ignoreEnv",
229
198
  "ignoreContext",
230
199
  "set",
231
- "shouldSavePrompt",
232
200
  "saveContextTo",
233
201
  "contextDir",
234
202
  "logLevel"
@@ -252,7 +220,6 @@ function logConfigLoaded(config) {
252
220
  if (config.ignoreContext === true) parts.push("ignoreContext=true");
253
221
  if (config.saveContextTo !== void 0) parts.push("saveContextTo=" + config.saveContextTo);
254
222
  if (config.contextDir !== void 0) parts.push("contextDir=" + config.contextDir);
255
- if (config.shouldSavePrompt !== void 0) parts.push("shouldSavePrompt=" + config.shouldSavePrompt);
256
223
  log("info", "config loaded", parts.join(" "));
257
224
  }
258
225
  if (levelNum >= LEVEL_NUM.debug) {
@@ -277,10 +244,21 @@ import {
277
244
  writeFileSync,
278
245
  mkdirSync,
279
246
  readdirSync,
280
- statSync
247
+ statSync,
248
+ existsSync as existsSync2
281
249
  } from "fs";
282
- import { join as join2, dirname as dirname2, isAbsolute } from "path";
250
+ import { join as join2, dirname as dirname2, isAbsolute, sep } from "path";
283
251
  var CONTEXT_SUFFIX = ".context.json";
252
+ var inMemoryContext = {};
253
+ function getInMemoryContext() {
254
+ return inMemoryContext;
255
+ }
256
+ function setInMemoryContext(key, value) {
257
+ inMemoryContext[key] = value;
258
+ }
259
+ function resetInMemoryContext() {
260
+ inMemoryContext = {};
261
+ }
284
262
  function discoverContextPathsInternal(dir, found) {
285
263
  let entries;
286
264
  try {
@@ -348,6 +326,14 @@ function getMergedContextValues() {
348
326
  const root = config.root ?? process.cwd();
349
327
  const paths = discoverContextPaths(root);
350
328
  const out = {};
329
+ if (config.saveContextTo) {
330
+ const savePath = resolveSaveContextPath(config.saveContextTo);
331
+ const saveCtx = getContextAtPath(savePath);
332
+ for (const [k, v] of Object.entries(saveCtx)) out[k] = v;
333
+ if (Object.keys(saveCtx).length > 0) {
334
+ log("debug", `saveContextTo "${config.saveContextTo}" loaded keys=${JSON.stringify(Object.keys(saveCtx))}`);
335
+ }
336
+ }
351
337
  for (const contextName of config.context ?? []) {
352
338
  const filePath = paths.get(contextName);
353
339
  if (!filePath) {
@@ -375,6 +361,9 @@ function getMergedContextValues() {
375
361
  return out;
376
362
  }
377
363
  function getContextWritePath(contextName) {
364
+ if (isAbsolute(contextName) || contextName.includes(sep)) {
365
+ return contextName.endsWith(CONTEXT_SUFFIX) ? contextName : contextName + CONTEXT_SUFFIX;
366
+ }
378
367
  const config = loadConfig();
379
368
  const root = config.root ?? process.cwd();
380
369
  const paths = discoverContextPaths(root);
@@ -383,6 +372,26 @@ function getContextWritePath(contextName) {
383
372
  const saveDir = config.contextDir ? isAbsolute(config.contextDir) ? config.contextDir : join2(root, config.contextDir) : root;
384
373
  return join2(saveDir, `${contextName}${CONTEXT_SUFFIX}`);
385
374
  }
375
+ function resolveSaveContextPath(nameOrPath) {
376
+ if (isAbsolute(nameOrPath) || nameOrPath.includes(sep)) {
377
+ return nameOrPath.endsWith(CONTEXT_SUFFIX) ? nameOrPath : nameOrPath + CONTEXT_SUFFIX;
378
+ }
379
+ return getContextWritePath(nameOrPath);
380
+ }
381
+ function getContextAtPath(filePath) {
382
+ if (!existsSync2(filePath)) return {};
383
+ try {
384
+ const raw = readFileSync2(filePath, "utf-8");
385
+ const data = JSON.parse(raw);
386
+ const out = {};
387
+ for (const [k, v] of Object.entries(data)) {
388
+ if (typeof v === "string") out[k] = v;
389
+ }
390
+ return out;
391
+ } catch {
392
+ return {};
393
+ }
394
+ }
386
395
  function writeToContext(contextName, key, value) {
387
396
  const path = getContextWritePath(contextName);
388
397
  let data = {};
@@ -479,6 +488,12 @@ function scenv(name, options = {}) {
479
488
  return { raw, source: "env" };
480
489
  }
481
490
  }
491
+ const mem = getInMemoryContext();
492
+ if (mem[key] !== void 0) {
493
+ log("trace", `resolveRaw: in-memory hit key=${key}`);
494
+ const raw = resolveContextReference(mem[key], key);
495
+ return { raw, source: "context" };
496
+ }
482
497
  if (!config.ignoreContext) {
483
498
  log("trace", "resolveRaw: checking context");
484
499
  const ctx = getMergedContextValues();
@@ -564,36 +579,11 @@ function scenv(name, options = {}) {
564
579
  const final = validated.data;
565
580
  if (wasPrompted) {
566
581
  const config = loadConfig();
567
- const mode = config.shouldSavePrompt ?? (config.prompt === "never" ? "never" : "ask");
568
- if (mode === "never") return final;
569
- let doSave;
570
- if (mode === "ask") {
571
- const callbacks2 = getCallbacks();
572
- if (typeof callbacks2.onAskWhetherToSave !== "function") {
573
- throw new Error(
574
- `shouldSavePrompt is "ask" but onAskWhetherToSave callback is not set. Configure callbacks via configure({ callbacks: { onAskWhetherToSave: ... } }).`
575
- );
576
- }
577
- doSave = await callbacks2.onAskWhetherToSave(name, final);
578
- } else {
579
- doSave = true;
580
- }
581
- if (!doSave) return final;
582
- const callbacks = getCallbacks();
583
- const contextNames = config.context ?? [];
584
- let ctxToSave;
585
- if (config.saveContextTo === "ask") {
586
- if (typeof callbacks.onAskContext !== "function") {
587
- throw new Error(
588
- `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
589
- );
590
- }
591
- ctxToSave = await callbacks.onAskContext(name, contextNames);
592
- } else {
593
- ctxToSave = config.saveContextTo ?? contextNames[0] ?? "default";
582
+ setInMemoryContext(key, String(final));
583
+ if (config.saveContextTo) {
584
+ writeToContext(config.saveContextTo, key, String(final));
585
+ log("info", `Saved key=${key} to saveContextTo ${config.saveContextTo}`);
594
586
  }
595
- writeToContext(ctxToSave, key, String(final));
596
- log("info", `Saved key=${key} to context ${ctxToSave}`);
597
587
  }
598
588
  return final;
599
589
  }
@@ -614,22 +604,11 @@ function scenv(name, options = {}) {
614
604
  throw new Error(errMsg);
615
605
  }
616
606
  const config = loadConfig();
617
- let contextName = config.saveContextTo;
618
- if (contextName === "ask") {
619
- const callbacks = getCallbacks();
620
- if (typeof callbacks.onAskContext !== "function") {
621
- throw new Error(
622
- `saveContextTo is "ask" but onAskContext callback is not set. Configure callbacks via configure({ callbacks: { onAskContext: ... } }).`
623
- );
624
- }
625
- contextName = await callbacks.onAskContext(
626
- name,
627
- config.context ?? []
628
- );
607
+ setInMemoryContext(key, String(validated.data));
608
+ if (config.saveContextTo) {
609
+ writeToContext(config.saveContextTo, key, String(validated.data));
610
+ log("info", `Saved key=${key} to saveContextTo ${config.saveContextTo}`);
629
611
  }
630
- if (!contextName) contextName = config.context?.[0] ?? "default";
631
- writeToContext(contextName, key, String(validated.data));
632
- log("info", `Saved key=${key} to context ${contextName}`);
633
612
  }
634
613
  return { get, safeGet, save };
635
614
  }
@@ -660,11 +639,6 @@ function parseScenvArgs(argv) {
660
639
  config.set = config.set ?? {};
661
640
  config.set[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
662
641
  }
663
- } else if (arg === "--save-prompt" && argv[i + 1] !== void 0) {
664
- const v = argv[++i].toLowerCase();
665
- if (["always", "never", "ask"].includes(v)) {
666
- config.shouldSavePrompt = v;
667
- }
668
642
  } else if (arg === "--save-context-to" && argv[i + 1] !== void 0) {
669
643
  config.saveContextTo = argv[++i];
670
644
  } else if (arg === "--context-dir" && argv[i + 1] !== void 0) {
@@ -697,10 +671,14 @@ export {
697
671
  discoverContextPaths,
698
672
  getCallbacks,
699
673
  getContext,
674
+ getContextWritePath,
675
+ getInMemoryContext,
700
676
  getMergedContextValues,
701
677
  loadConfig,
702
678
  parseScenvArgs,
703
679
  resetConfig,
680
+ resetInMemoryContext,
704
681
  resetLogState,
705
- scenv
682
+ scenv,
683
+ setInMemoryContext
706
684
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenv",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Environment and context variables with runtime-configurable resolution",
5
5
  "repository": {
6
6
  "type": "git",