feat(credentials): add JSON5 credential system
This commit is contained in:
parent
b1d80f29ae
commit
3ee8946e29
10 changed files with 454 additions and 110 deletions
202
src/agent/credentials.ts
Normal file
202
src/agent/credentials.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import JSON5 from "json5";
|
||||
import { DATA_DIR } from "../shared/paths.js";
|
||||
|
||||
type ProviderConfig = {
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
model?: string | undefined;
|
||||
};
|
||||
|
||||
type ToolConfig = {
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
model?: string | undefined;
|
||||
};
|
||||
|
||||
export type CredentialsConfig = {
|
||||
version?: number | undefined;
|
||||
llm?: {
|
||||
provider?: string | undefined;
|
||||
providers?: Record<string, ProviderConfig> | undefined;
|
||||
} | undefined;
|
||||
tools?: Record<string, ToolConfig> | undefined;
|
||||
};
|
||||
|
||||
type SkillsEnvConfig = {
|
||||
env?: Record<string, string> | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIALS_PATH = join(DATA_DIR, "credentials.json5");
|
||||
const DEFAULT_SKILLS_ENV_PATH = join(DATA_DIR, "skills.env.json5");
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") return homedir();
|
||||
if (value.startsWith("~/")) {
|
||||
return join(homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTestEnv(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.VITEST !== undefined ||
|
||||
process.env.VITEST_WORKER_ID !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
function setEnvValue(target: Record<string, string>, key: string, value: unknown): void {
|
||||
if (isString(value)) {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEnvMap(target: Record<string, string>, env?: Record<string, string>): void {
|
||||
if (!env) return;
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
setEnvValue(target, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCredentialsPath(): string {
|
||||
const raw = process.env.SMC_CREDENTIALS_PATH ?? DEFAULT_CREDENTIALS_PATH;
|
||||
return expandHome(raw);
|
||||
}
|
||||
|
||||
export function getSkillsEnvPath(): string {
|
||||
const raw = process.env.SMC_SKILLS_ENV_PATH ?? DEFAULT_SKILLS_ENV_PATH;
|
||||
return expandHome(raw);
|
||||
}
|
||||
|
||||
export class CredentialManager {
|
||||
private corePath: string | null = null;
|
||||
private skillsPath: string | null = null;
|
||||
private disabledState: boolean | null = null;
|
||||
private coreConfig: CredentialsConfig | null = null;
|
||||
private skillsConfig: SkillsEnvConfig | null = null;
|
||||
private resolvedSkillsEnv: Record<string, string> | null = null;
|
||||
|
||||
private isDisabled(): boolean {
|
||||
if (process.env.SMC_CREDENTIALS_DISABLE === "1") return true;
|
||||
return isTestEnv();
|
||||
}
|
||||
|
||||
private loadCore(): void {
|
||||
const path = getCredentialsPath();
|
||||
const disabled = this.isDisabled();
|
||||
|
||||
if (this.corePath === path && this.disabledState === disabled && this.coreConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.corePath = path;
|
||||
this.disabledState = disabled;
|
||||
this.coreConfig = null;
|
||||
|
||||
if (disabled) return;
|
||||
if (!existsSync(path)) return;
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
try {
|
||||
this.coreConfig = JSON5.parse(raw) as CredentialsConfig;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse credentials file (${path}): ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadSkillsEnv(): void {
|
||||
const path = getSkillsEnvPath();
|
||||
const disabled = this.isDisabled();
|
||||
|
||||
if (this.skillsPath === path && this.disabledState === disabled && this.resolvedSkillsEnv) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.skillsPath = path;
|
||||
this.disabledState = disabled;
|
||||
this.skillsConfig = null;
|
||||
this.resolvedSkillsEnv = null;
|
||||
|
||||
if (disabled) return;
|
||||
if (!existsSync(path)) return;
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
try {
|
||||
this.skillsConfig = JSON5.parse(raw) as SkillsEnvConfig;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse skills env file (${path}): ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSkillsEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (!this.skillsConfig) return env;
|
||||
|
||||
applyEnvMap(env, this.skillsConfig.env);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private getResolvedSkillsEnv(): Record<string, string> {
|
||||
this.loadSkillsEnv();
|
||||
if (!this.resolvedSkillsEnv) {
|
||||
this.resolvedSkillsEnv = this.buildSkillsEnv();
|
||||
}
|
||||
return this.resolvedSkillsEnv;
|
||||
}
|
||||
|
||||
getLlmProvider(): string | undefined {
|
||||
this.loadCore();
|
||||
return this.coreConfig?.llm?.provider;
|
||||
}
|
||||
|
||||
getLlmProviderConfig(provider: string): ProviderConfig | undefined {
|
||||
this.loadCore();
|
||||
return this.coreConfig?.llm?.providers?.[provider];
|
||||
}
|
||||
|
||||
getToolConfig(toolName: string): ToolConfig | undefined {
|
||||
this.loadCore();
|
||||
return this.coreConfig?.tools?.[toolName];
|
||||
}
|
||||
|
||||
getEnv(name: string): string | undefined {
|
||||
const resolved = this.getResolvedSkillsEnv();
|
||||
if (Object.prototype.hasOwnProperty.call(resolved, name)) {
|
||||
return resolved[name];
|
||||
}
|
||||
return process.env[name];
|
||||
}
|
||||
|
||||
hasEnv(name: string): boolean {
|
||||
const resolved = this.getResolvedSkillsEnv();
|
||||
if (Object.prototype.hasOwnProperty.call(resolved, name)) {
|
||||
return true;
|
||||
}
|
||||
return name in process.env;
|
||||
}
|
||||
|
||||
getResolvedEnvSnapshot(): Record<string, string> {
|
||||
return { ...this.getResolvedSkillsEnv() };
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.corePath = null;
|
||||
this.skillsPath = null;
|
||||
this.disabledState = null;
|
||||
this.coreConfig = null;
|
||||
this.skillsConfig = null;
|
||||
this.resolvedSkillsEnv = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const credentialManager = new CredentialManager();
|
||||
Loading…
Add table
Add a link
Reference in a new issue