diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts index bab7431..981fd85 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts @@ -694,6 +694,10 @@ async function main(): Promise { } 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); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts new file mode 100644 index 0000000..d3a1281 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts @@ -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>, +): 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 { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function writeEnvFile(root: string, content: string): Promise { + 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, "/.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- \/\.baoyu-skills\/\.env missing WECHAT_APP_ID/, + ); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts index da6c0c0..2196980 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts @@ -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, + 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(`/.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("/.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[] {