@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.
- package/dist/lib/config.d.mts +3 -0
- package/dist/lib/config.mjs +52 -10
- package/package.json +1 -1
package/dist/lib/config.d.mts
CHANGED
|
@@ -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(
|
package/dist/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
const
|
|
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.
|
|
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": {
|