@golden-frijoles/kit 0.5.1 → 0.5.3

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.
@@ -51,6 +51,9 @@ export declare function setKey(
51
51
  export declare function migrate(
52
52
  opts?: IoOptions & { write?: (path: string, obj: unknown) => void; dryRun?: boolean }
53
53
  ): { config: Record<string, unknown>; folded: string[]; skipped: string[] };
54
+ /** What `loadConfig`/`getKey` print in place of a secret-looking value. */
55
+ export declare const REDACTED: string;
56
+ export declare function redactSecrets<T>(key: string, value: T): T;
54
57
  export declare function looksLikeSecret(key: string, value: unknown): boolean;
55
58
  export declare function needsSettingLine(entry: { key: string; question: string; default: unknown }): string;
56
59
  export declare function needSetting(
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
21
21
  import { join, resolve, sep } from 'node:path';
22
- import { projectRoot } from './project-root.mjs';
22
+ import { kitRoot, projectRoot } from './project-root.mjs';
23
23
  import { REGISTRY } from './config-registry.mjs';
24
24
 
25
25
  // One import for every front end (D10): the CLI's doctor and setup read the registry through this module too.
@@ -101,13 +101,26 @@ const configFail = (path, e) => {
101
101
  */
102
102
  function assertContained(path, root, read) {
103
103
  if (read !== readFileSync) return;
104
- const realRoot = realpathSync(root);
105
104
  const real = realpathSync(path);
106
- if (real !== realRoot && !real.startsWith(realRoot + sep)) {
105
+ const within = (dir) => {
106
+ const r = realpathSync(dir);
107
+ return real === r || real.startsWith(r + sep);
108
+ };
109
+ // The kit's own files (a bundled default, read in installed mode) are not the checkout's to redirect.
110
+ if (!within(root) && !within(kitRoot())) {
107
111
  throw new ConfigError(`${path} resolves outside the project (${real}); refusing to read it.`);
108
112
  }
109
113
  }
110
114
 
115
+ /**
116
+ * REPORTING_CONFIG is the operator's env, not the checkout's: the file it names may live anywhere. Keyed on the PATH,
117
+ * so it holds whether a rail passes that path as `legacyPath` (reporting-config.mjs does) or the table resolves it
118
+ * (copy-in review: the first exemption tested `!legacyPath` and refused the rail's own call).
119
+ */
120
+ function operatorPath(name, abs, { root, env }) {
121
+ return name === 'reporting' && !!env.REPORTING_CONFIG && abs === resolve(root, env.REPORTING_CONFIG);
122
+ }
123
+
111
124
  /** The project's `golden-frijoles.config.json`, parsed; `null` when absent. Malformed → ConfigError. */
112
125
  export function readConfigFile({ root = projectRoot(), read = readFileSync, exists = existsSync } = {}) {
113
126
  const path = join(root, CONFIG_FILENAME);
@@ -176,7 +189,7 @@ export function readSection(
176
189
  let fromLegacy;
177
190
  if (legacyAbs && legacyExists(legacyAbs)) {
178
191
  // REPORTING_CONFIG is the operator's env, not the checkout's: it may name a file anywhere.
179
- if (!(name === 'reporting' && env.REPORTING_CONFIG && !legacyPath)) assertContained(legacyAbs, root, legacyRead);
192
+ if (!operatorPath(name, legacyAbs, { root, env })) assertContained(legacyAbs, root, legacyRead);
180
193
  fromLegacy = parseJsonFile(legacyAbs, { read: legacyRead, onError: onLegacyError });
181
194
  }
182
195
 
@@ -202,8 +215,12 @@ export function loadConfig({ root = projectRoot(), read = readFileSync, exists =
202
215
  const unknown = unknownSections(readConfigFile({ root, read, exists }));
203
216
  for (const name of [...SECTIONS, ...Object.keys(LEGACY).filter((k) => k.includes('.'))]) {
204
217
  const r = readSection(name, { root, read, exists });
205
- if (r.raw === null) continue;
206
- sections[name] = r.raw;
218
+ if (r.raw === null) {
219
+ // Present but JSON null is malformed, not "no settings" (copy-in review).
220
+ if (r.present) throw new ConfigError(`${r.sources[0] ?? name}: "${name}" must be an object, not null`);
221
+ continue;
222
+ }
223
+ sections[name] = redactSecrets(name, r.raw);
207
224
  sources[name] = r.sources;
208
225
  for (const k of r.duplicates) duplicates.push(`${name}.${k}`);
209
226
  }
@@ -217,7 +234,7 @@ export function getKey(key, opts = {}) {
217
234
  const sub = rest.length && LEGACY[`${head}.${rest[0]}`] ? `${head}.${rest.shift()}` : head;
218
235
  const { raw } = readSection(sub, opts);
219
236
  const value = rest.length ? dig(raw ?? {}, rest.join('.')) : raw ?? undefined;
220
- if (value !== undefined) return value;
237
+ if (value !== undefined) return redactSecrets(key, value);
221
238
  const reg = REGISTRY.find((r) => r.key === key);
222
239
  return reg ? reg.default : undefined;
223
240
  }
@@ -247,6 +264,25 @@ export function looksLikeSecret(key, raw) {
247
264
  return SECRET_KEY.test(leaf) && !ENV_NAME.test(value);
248
265
  }
249
266
 
267
+ export const REDACTED = '<redacted: looks like a secret; keep it in .env.local>';
268
+
269
+ /**
270
+ * Pure — `value` with every secret-looking string replaced by REDACTED. `loadConfig` and `getKey` are what
271
+ * `config list/get` and `gf doctor` PRINT, and a legacy file may still hold a literal token the write guard never saw
272
+ * (copy-in review). Rails read through `readSection`, which is never redacted.
273
+ */
274
+ export function redactSecrets(key, value) {
275
+ if (Array.isArray(value)) return value.map((v, i) => redactSecrets(`${key}.${i}`, v));
276
+ if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSecrets(`${key}.${k}`, v)]));
277
+ if (looksLikeSecret(key, value)) return REDACTED;
278
+ // Stricter than the write guard, because over-redacting a display costs nothing: under a secret-named key, only a
279
+ // value shaped like an env var NAME with an underscore (TELEGRAM_BOT_TOKEN) is shown. `ABCDEF1234567890` matched
280
+ // the looser env-name exemption and was printed (review of #53).
281
+ const leaf = key.split('.').pop();
282
+ if (typeof value === 'string' && SECRET_KEY.test(leaf) && !/^[A-Z][A-Z0-9]*_[A-Z0-9_]*$/.test(value.trim())) return REDACTED;
283
+ return value;
284
+ }
285
+
250
286
  /** Pure — every dotted path under `key` whose value looks like a secret, walking nested objects and arrays. */
251
287
  export function findSecrets(key, value) {
252
288
  if (Array.isArray(value)) return value.flatMap((v, i) => findSecrets(`${key}.${i}`, v));
@@ -322,12 +358,18 @@ export function migrate({
322
358
  for (const name of Object.keys(LEGACY)) {
323
359
  const abs = legacyPathFor(name, { root, env });
324
360
  if (!exists(abs)) continue;
325
- if (!(name === 'reporting' && env.REPORTING_CONFIG)) assertContained(abs, root, read);
361
+ if (!operatorPath(name, abs, { root, env })) assertContained(abs, root, read);
326
362
  const legacy = parseJsonFile(abs, { read, onError: configFail });
327
363
  if (!isObject(legacy)) continue;
328
364
  const [head, sub] = name.split('.');
329
- config[head] = isObject(config[head]) ? config[head] : {};
330
- const target = sub ? (config[head][sub] = isObject(config[head][sub]) ? config[head][sub] : {}) : config[head];
365
+ // A section that exists but is not an object is the user's to fix, never silently replaced (copy-in review).
366
+ const notObject = (v, at) => {
367
+ if (v !== undefined && !isObject(v)) throw new ConfigError(`${join(root, CONFIG_FILENAME)}: "${at}" must be an object`);
368
+ };
369
+ notObject(config[head], head);
370
+ config[head] = config[head] ?? {};
371
+ if (sub) notObject(config[head][sub], name);
372
+ const target = sub ? (config[head][sub] = config[head][sub] ?? {}) : config[head];
331
373
  for (const [k, v] of Object.entries(legacy)) {
332
374
  if (k.startsWith('$') || k.startsWith('_')) continue; // comments/notes stay with the legacy file
333
375
  if (k in target) continue; // the new file wins
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {