feat(baoyu-post-to-wechat): improve credential loading with multi-source resolution and diagnostics
This commit is contained in:
parent
3dc5f2e06f
commit
e4cd8bfefc
|
|
@ -694,6 +694,10 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
const creds = loadCredentials(resolved);
|
||||
for (const skippedSource of creds.skippedSources) {
|
||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||
}
|
||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { loadCredentials } from "./wechat-extend-config.ts";
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
t.after(() => {
|
||||
process.chdir(previous);
|
||||
});
|
||||
}
|
||||
|
||||
function useHome(t: TestContext, home: string): void {
|
||||
const previous = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
t.after(() => {
|
||||
if (previous === undefined) {
|
||||
delete process.env.HOME;
|
||||
return;
|
||||
}
|
||||
process.env.HOME = previous;
|
||||
});
|
||||
}
|
||||
|
||||
function useWechatEnv(
|
||||
t: TestContext,
|
||||
values: Partial<Record<"WECHAT_APP_ID" | "WECHAT_APP_SECRET", string | undefined>>,
|
||||
): void {
|
||||
const previous = {
|
||||
WECHAT_APP_ID: process.env.WECHAT_APP_ID,
|
||||
WECHAT_APP_SECRET: process.env.WECHAT_APP_SECRET,
|
||||
};
|
||||
|
||||
if (values.WECHAT_APP_ID === undefined) {
|
||||
delete process.env.WECHAT_APP_ID;
|
||||
} else {
|
||||
process.env.WECHAT_APP_ID = values.WECHAT_APP_ID;
|
||||
}
|
||||
|
||||
if (values.WECHAT_APP_SECRET === undefined) {
|
||||
delete process.env.WECHAT_APP_SECRET;
|
||||
} else {
|
||||
process.env.WECHAT_APP_SECRET = values.WECHAT_APP_SECRET;
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
if (previous.WECHAT_APP_ID === undefined) {
|
||||
delete process.env.WECHAT_APP_ID;
|
||||
} else {
|
||||
process.env.WECHAT_APP_ID = previous.WECHAT_APP_ID;
|
||||
}
|
||||
|
||||
if (previous.WECHAT_APP_SECRET === undefined) {
|
||||
delete process.env.WECHAT_APP_SECRET;
|
||||
} else {
|
||||
process.env.WECHAT_APP_SECRET = previous.WECHAT_APP_SECRET;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function writeEnvFile(root: string, content: string): Promise<void> {
|
||||
const envPath = path.join(root, ".baoyu-skills", ".env");
|
||||
await fs.mkdir(path.dirname(envPath), { recursive: true });
|
||||
await fs.writeFile(envPath, content);
|
||||
}
|
||||
|
||||
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useWechatEnv(t, {
|
||||
WECHAT_APP_ID: undefined,
|
||||
WECHAT_APP_SECRET: "stale-secret-from-process-env",
|
||||
});
|
||||
|
||||
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
|
||||
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
|
||||
|
||||
const credentials = loadCredentials();
|
||||
|
||||
assert.equal(credentials.appId, "cwd-app-id");
|
||||
assert.equal(credentials.appSecret, "cwd-app-secret");
|
||||
assert.equal(credentials.source, "<cwd>/.baoyu-skills/.env");
|
||||
assert.deepEqual(credentials.skippedSources, [
|
||||
"process.env missing WECHAT_APP_ID",
|
||||
]);
|
||||
});
|
||||
|
||||
test("loadCredentials prefers a complete process.env pair over lower-priority files", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useWechatEnv(t, {
|
||||
WECHAT_APP_ID: "env-app-id",
|
||||
WECHAT_APP_SECRET: "env-app-secret",
|
||||
});
|
||||
|
||||
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
|
||||
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
|
||||
|
||||
const credentials = loadCredentials();
|
||||
|
||||
assert.equal(credentials.appId, "env-app-id");
|
||||
assert.equal(credentials.appSecret, "env-app-secret");
|
||||
assert.equal(credentials.source, "process.env");
|
||||
assert.deepEqual(credentials.skippedSources, []);
|
||||
});
|
||||
|
||||
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useWechatEnv(t, {
|
||||
WECHAT_APP_ID: "env-app-id",
|
||||
WECHAT_APP_SECRET: undefined,
|
||||
});
|
||||
|
||||
await writeEnvFile(cwdRoot, "WECHAT_APP_SECRET=cwd-app-secret\n");
|
||||
|
||||
assert.throws(
|
||||
() => loadCredentials(),
|
||||
/Incomplete credential sources skipped:\n- process\.env missing WECHAT_APP_SECRET\n- <cwd>\/\.baoyu-skills\/\.env missing WECHAT_APP_ID/,
|
||||
);
|
||||
});
|
||||
|
|
@ -196,48 +196,116 @@ function aliasToEnvKey(alias: string): string {
|
|||
return alias.toUpperCase().replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } {
|
||||
if (account?.app_id && account?.app_secret) {
|
||||
return { appId: account.app_id, appSecret: account.app_secret };
|
||||
interface CredentialSource {
|
||||
name: string;
|
||||
appIdKey: string;
|
||||
appSecretKey: string;
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
}
|
||||
|
||||
export interface LoadedCredentials {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
source: string;
|
||||
skippedSources: string[];
|
||||
}
|
||||
|
||||
function normalizeCredentialValue(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function describeMissingKeys(source: CredentialSource): string {
|
||||
const missingKeys: string[] = [];
|
||||
if (!source.appId) missingKeys.push(source.appIdKey);
|
||||
if (!source.appSecret) missingKeys.push(source.appSecretKey);
|
||||
return `${source.name} missing ${missingKeys.join(" and ")}`;
|
||||
}
|
||||
|
||||
function buildCredentialSource(
|
||||
name: string,
|
||||
values: Record<string, string | undefined>,
|
||||
appIdKey: string,
|
||||
appSecretKey: string,
|
||||
): CredentialSource {
|
||||
return {
|
||||
name,
|
||||
appIdKey,
|
||||
appSecretKey,
|
||||
appId: normalizeCredentialValue(values[appIdKey]),
|
||||
appSecret: normalizeCredentialValue(values[appSecretKey]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCredentialSource(
|
||||
sources: CredentialSource[],
|
||||
account?: ResolvedAccount,
|
||||
): LoadedCredentials {
|
||||
const skippedSources: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.appId && source.appSecret) {
|
||||
return {
|
||||
appId: source.appId,
|
||||
appSecret: source.appSecret,
|
||||
source: source.name,
|
||||
skippedSources,
|
||||
};
|
||||
}
|
||||
|
||||
if (source.appId || source.appSecret) {
|
||||
skippedSources.push(describeMissingKeys(source));
|
||||
}
|
||||
}
|
||||
|
||||
const hint = account?.alias ? ` (account: ${account.alias})` : "";
|
||||
const partialHint = skippedSources.length > 0
|
||||
? `\nIncomplete credential sources skipped:\n- ${skippedSources.join("\n- ")}`
|
||||
: "";
|
||||
|
||||
throw new Error(
|
||||
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
|
||||
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file." +
|
||||
partialHint
|
||||
);
|
||||
}
|
||||
|
||||
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||
const homeEnv = loadEnvFile(homeEnvPath);
|
||||
|
||||
const sources: CredentialSource[] = [];
|
||||
|
||||
if (account?.app_id || account?.app_secret) {
|
||||
sources.push({
|
||||
name: account.alias ? `EXTEND.md account "${account.alias}"` : "EXTEND.md account config",
|
||||
appIdKey: "app_id",
|
||||
appSecretKey: "app_secret",
|
||||
appId: normalizeCredentialValue(account.app_id),
|
||||
appSecret: normalizeCredentialValue(account.app_secret),
|
||||
});
|
||||
}
|
||||
|
||||
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
|
||||
|
||||
let appId = "";
|
||||
let appSecret = "";
|
||||
|
||||
if (prefix) {
|
||||
appId = process.env[`${prefix}APP_ID`]
|
||||
|| cwdEnv[`${prefix}APP_ID`]
|
||||
|| homeEnv[`${prefix}APP_ID`]
|
||||
|| "";
|
||||
appSecret = process.env[`${prefix}APP_SECRET`]
|
||||
|| cwdEnv[`${prefix}APP_SECRET`]
|
||||
|| homeEnv[`${prefix}APP_SECRET`]
|
||||
|| "";
|
||||
}
|
||||
|
||||
if (!appId) {
|
||||
appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || "";
|
||||
}
|
||||
if (!appSecret) {
|
||||
appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || "";
|
||||
}
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
const hint = account?.alias ? ` (account: ${account.alias})` : "";
|
||||
throw new Error(
|
||||
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
|
||||
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file."
|
||||
const prefixedKeyLabel = `${prefix}APP_ID/${prefix}APP_SECRET`;
|
||||
sources.push(
|
||||
buildCredentialSource(`process.env (${prefixedKeyLabel})`, process.env, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||
buildCredentialSource(`<cwd>/.baoyu-skills/.env (${prefixedKeyLabel})`, cwdEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||
buildCredentialSource(`~/.baoyu-skills/.env (${prefixedKeyLabel})`, homeEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||
);
|
||||
}
|
||||
|
||||
return { appId, appSecret };
|
||||
sources.push(
|
||||
buildCredentialSource("process.env", process.env, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||
buildCredentialSource("<cwd>/.baoyu-skills/.env", cwdEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||
buildCredentialSource("~/.baoyu-skills/.env", homeEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||
);
|
||||
|
||||
return resolveCredentialSource(sources, account);
|
||||
}
|
||||
|
||||
export function listAccounts(config: WechatExtendConfig): string[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue