Skip to content

Commit dc76dea

Browse files
leopu00claude
andcommitted
feat(config): JSON5 parse, hot reload fs.watch, SecretRef in types
io.ts: parseJson5 (strip comments + trailing commas), scrittura atomica tmp+rename, onConfigChange con fs.watch debounced. types.ts: aggiunto api_key_ref SecretRef. index.ts: re-export nuovi simboli. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7198bde commit dc76dea

3 files changed

Lines changed: 93 additions & 18 deletions

File tree

shared/config/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ export {
3030
writeConfig,
3131
configExists,
3232
redactConfig,
33+
parseJson5,
34+
onConfigChange,
3335
JHT_CONFIG_DIR,
3436
JHT_CONFIG_PATH,
3537
} from "./io";
38+
39+
export {
40+
resolveSecret,
41+
createSecretRef,
42+
describeSecret,
43+
} from "./secret-ref";
44+
export type {
45+
SecretRef,
46+
SecretPlaintext,
47+
SecretEnvRef,
48+
SecretFileRef,
49+
SecretExecRef,
50+
} from "./secret-ref";

shared/config/io.ts

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
2-
* JHT Config — IO utilities per ~/.jht/jht.config.json
2+
* JHT Config — IO utilities per ~/.jht/config.json
33
*
4-
* Lettura, scrittura e validazione del file di configurazione.
4+
* Lettura (JSON5-compatibile), scrittura, validazione e hot reload.
5+
* Pattern copiato da OpenClaw (openclaw/src/config/io.ts).
56
*/
67

78
import * as fs from "node:fs";
@@ -17,11 +18,27 @@ export const JHT_CONFIG_DIR = path.join(os.homedir(), ".jht");
1718
export const JHT_CONFIG_PATH = path.join(JHT_CONFIG_DIR, "jht.config.json");
1819

1920
/** Campi sensibili da mascherare nei log */
20-
const SENSITIVE_FIELDS = ["api_key", "bot_token", "session_token"];
21+
const SENSITIVE_FIELDS = ["api_key", "bot_token", "session_token", "value"];
22+
23+
// --- JSON5 leggero: strip commenti e trailing commas ---
24+
25+
/**
26+
* Parsa JSON5 leggero: rimuove commenti // e /* e trailing commas.
27+
* Non richiede dipendenze esterne.
28+
*/
29+
export function parseJson5(raw: string): unknown {
30+
let cleaned = raw;
31+
// Rimuovi commenti single-line (// ...) fuori da stringhe
32+
cleaned = cleaned.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, (_, str) => str ?? "");
33+
// Rimuovi commenti multi-line (/* ... */)
34+
cleaned = cleaned.replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (_, str) => str ?? "");
35+
// Rimuovi trailing commas prima di } o ]
36+
cleaned = cleaned.replace(/,(\s*[}\]])/g, "$1");
37+
return JSON.parse(cleaned);
38+
}
2139

2240
/**
23-
* Legge e valida jht.config.json.
24-
* Ritorna { success, data, error }.
41+
* Legge e valida il config. Supporta JSON e JSON5 (commenti, trailing commas).
2542
*/
2643
export function readConfig(): {
2744
success: boolean;
@@ -41,9 +58,9 @@ export function readConfig(): {
4158

4259
let parsed: unknown;
4360
try {
44-
parsed = JSON.parse(raw);
61+
parsed = parseJson5(raw);
4562
} catch {
46-
return { success: false, error: "JSON non valido in jht.config.json" };
63+
return { success: false, error: "JSON non valido in config" };
4764
}
4865

4966
const result = validateConfig(parsed);
@@ -56,8 +73,8 @@ export function readConfig(): {
5673
}
5774

5875
/**
59-
* Scrive jht.config.json dopo validazione.
60-
* Crea la directory ~/.jht/ se non esiste.
76+
* Scrive config dopo validazione. Crea ~/.jht/ se non esiste.
77+
* Scrive JSON formattato (non JSON5 — i commenti si perderebbero).
6178
*/
6279
export function writeConfig(config: unknown): {
6380
success: boolean;
@@ -72,25 +89,28 @@ export function writeConfig(config: unknown): {
7289

7390
try {
7491
fs.mkdirSync(JHT_CONFIG_DIR, { recursive: true });
75-
fs.writeFileSync(JHT_CONFIG_PATH, JSON.stringify(result.data, null, 2) + "\n", "utf-8");
92+
// Scrittura atomica: tmp + rename
93+
const tmpPath = JHT_CONFIG_PATH + ".tmp";
94+
fs.writeFileSync(tmpPath, JSON.stringify(result.data, null, 2) + "\n", "utf-8");
95+
fs.renameSync(tmpPath, JHT_CONFIG_PATH);
7696
} catch (err) {
7797
return { success: false, error: `Errore scrittura file: ${(err as Error).message}` };
7898
}
7999

100+
// Notifica listener hot reload
101+
for (const cb of configChangeListeners) {
102+
try { cb(result.data!); } catch { /* ignora errori listener */ }
103+
}
104+
80105
return { success: true, data: result.data };
81106
}
82107

83-
/**
84-
* Verifica se il file config esiste.
85-
*/
108+
/** Verifica se il file config esiste. */
86109
export function configExists(): boolean {
87110
return fs.existsSync(JHT_CONFIG_PATH);
88111
}
89112

90-
/**
91-
* Ritorna una copia del config con i campi sensibili mascherati.
92-
* Utile per logging e debug.
93-
*/
113+
/** Copia config con campi sensibili mascherati. */
94114
export function redactConfig(config: Record<string, unknown>): Record<string, unknown> {
95115
return JSON.parse(JSON.stringify(config), (key, value) => {
96116
if (SENSITIVE_FIELDS.includes(key) && typeof value === "string" && value.length > 0) {
@@ -99,3 +119,41 @@ export function redactConfig(config: Record<string, unknown>): Record<string, un
99119
return value;
100120
});
101121
}
122+
123+
// --- Hot Reload ---
124+
125+
type ConfigChangeCallback = (config: JHTConfigParsed) => void;
126+
const configChangeListeners: ConfigChangeCallback[] = [];
127+
let watcher: fs.FSWatcher | null = null;
128+
129+
/** Registra un callback per hot reload del config. */
130+
export function onConfigChange(callback: ConfigChangeCallback): () => void {
131+
configChangeListeners.push(callback);
132+
133+
// Avvia watcher al primo listener
134+
if (!watcher && fs.existsSync(JHT_CONFIG_PATH)) {
135+
let debounce: ReturnType<typeof setTimeout> | null = null;
136+
watcher = fs.watch(JHT_CONFIG_PATH, () => {
137+
if (debounce) clearTimeout(debounce);
138+
debounce = setTimeout(() => {
139+
const result = readConfig();
140+
if (result.success && result.data) {
141+
for (const cb of configChangeListeners) {
142+
try { cb(result.data); } catch { /* ignora */ }
143+
}
144+
}
145+
}, 200);
146+
});
147+
if (watcher.unref) watcher.unref();
148+
}
149+
150+
// Ritorna funzione di unsubscribe
151+
return () => {
152+
const idx = configChangeListeners.indexOf(callback);
153+
if (idx >= 0) configChangeListeners.splice(idx, 1);
154+
if (configChangeListeners.length === 0 && watcher) {
155+
watcher.close();
156+
watcher = null;
157+
}
158+
};
159+
}

shared/config/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export type AuthMethod = "api_key" | "subscription";
1414
export interface AIProviderConfig {
1515
name: AIProviderName;
1616
auth_method: AuthMethod;
17-
/** Obbligatorio se auth_method = "api_key" */
17+
/** Obbligatorio se auth_method = "api_key" (plaintext legacy) */
1818
api_key?: string;
19+
/** SecretRef per API key (env/file/exec) — preferito a api_key plaintext */
20+
api_key_ref?: import("./secret-ref").SecretRef;
1921
/** Obbligatorio se auth_method = "subscription" */
2022
subscription?: SubscriptionConfig;
2123
/** Modello da usare (es. "claude-opus-4-6", "gpt-4o", "minimax-01") */

0 commit comments

Comments
 (0)