mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-21 09:49:48 +08:00
3b29f3c57c
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist constraints) with a fully local code path: spawn a background `ssh -N -D` SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through the existing token/upload/draft flow. No Python helper, no SCP, no remote files, no `AppSecret` ever leaving the local process. Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag rather than substring-replacing `src`) naturally avoids the original PR's `html.replace(src, url)` HTML-replacement bug. Changes: - New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port allocation, SOCKS readiness polling, signal-handler-backed cleanup. - New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a `SocksProxyAgent` can be passed through (undici `fetch` ignores agent). - New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse. - `wechat-api.ts`: thread optional `agent?` through token/upload/draft; add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote mode is selected by flag or `default_publish_method: remote-api`. - `wechat-extend-config.ts`: add typed `remote_publish_*` keys with account-over-global fallback and value validation. - Docs: SKILL.md, references/multi-account.md, references/config/ first-time-setup.md, README.md, README.zh.md. - Tests: 17 new node:test cases covering config parsing, SSH arg whitelisting, free-port allocation, multipart assembly, and HTTP agent threading. Co-authored-by: Dame5211 <1079825614@qq.com>
275 lines
9.1 KiB
TypeScript
275 lines
9.1 KiB
TypeScript
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,
|
|
loadWechatExtendConfig,
|
|
resolveAccount,
|
|
} 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);
|
|
}
|
|
|
|
async function writeExtendFile(root: string, content: string): Promise<void> {
|
|
const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md");
|
|
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
|
await fs.writeFile(extendPath, content);
|
|
}
|
|
|
|
function useXdgConfigHome(t: TestContext, value: string | undefined): void {
|
|
const previous = process.env.XDG_CONFIG_HOME;
|
|
if (value === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = value;
|
|
}
|
|
t.after(() => {
|
|
if (previous === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
return;
|
|
}
|
|
process.env.XDG_CONFIG_HOME = previous;
|
|
});
|
|
}
|
|
|
|
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("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => {
|
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
|
|
|
useCwd(t, cwdRoot);
|
|
useHome(t, homeRoot);
|
|
useXdgConfigHome(t, undefined);
|
|
|
|
await writeExtendFile(
|
|
cwdRoot,
|
|
[
|
|
"default_publish_method: remote-api",
|
|
"remote_publish_host: bastion.example.com",
|
|
"remote_publish_user: deploy",
|
|
"remote_publish_port: 2222",
|
|
"remote_publish_identity_file: /home/me/.ssh/id_ed25519",
|
|
"remote_publish_known_hosts_file: /home/me/.ssh/known_hosts",
|
|
"remote_publish_strict_host_key_checking: accept-new",
|
|
"remote_publish_connect_timeout: 12",
|
|
"remote_publish_proxy_jump: jump.example.com",
|
|
].join("\n"),
|
|
);
|
|
|
|
const config = loadWechatExtendConfig();
|
|
const resolved = resolveAccount(config);
|
|
|
|
assert.equal(resolved.default_publish_method, "remote-api");
|
|
assert.equal(resolved.remote_publish_host, "bastion.example.com");
|
|
assert.equal(resolved.remote_publish_user, "deploy");
|
|
assert.equal(resolved.remote_publish_port, 2222);
|
|
assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519");
|
|
assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts");
|
|
assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new");
|
|
assert.equal(resolved.remote_publish_connect_timeout, 12);
|
|
assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com");
|
|
});
|
|
|
|
test("resolveAccount lets account-level remote_publish_* override globals", async (t) => {
|
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
|
|
|
useCwd(t, cwdRoot);
|
|
useHome(t, homeRoot);
|
|
useXdgConfigHome(t, undefined);
|
|
|
|
await writeExtendFile(
|
|
cwdRoot,
|
|
[
|
|
"default_publish_method: browser",
|
|
"remote_publish_host: global.example.com",
|
|
"remote_publish_user: deploy",
|
|
"remote_publish_port: 22",
|
|
"accounts:",
|
|
" - name: Primary",
|
|
" alias: primary",
|
|
" default: true",
|
|
" remote_publish_host: primary.example.com",
|
|
" remote_publish_user: primary-user",
|
|
" remote_publish_port: 2200",
|
|
" remote_publish_identity_file: /p/id_primary",
|
|
" - name: Secondary",
|
|
" alias: secondary",
|
|
" remote_publish_proxy_jump: jump.example.com",
|
|
].join("\n"),
|
|
);
|
|
|
|
const config = loadWechatExtendConfig();
|
|
const primary = resolveAccount(config, "primary");
|
|
assert.equal(primary.alias, "primary");
|
|
assert.equal(primary.remote_publish_host, "primary.example.com");
|
|
assert.equal(primary.remote_publish_user, "primary-user");
|
|
assert.equal(primary.remote_publish_port, 2200);
|
|
assert.equal(primary.remote_publish_identity_file, "/p/id_primary");
|
|
assert.equal(primary.remote_publish_proxy_jump, undefined);
|
|
|
|
const secondary = resolveAccount(config, "secondary");
|
|
assert.equal(secondary.alias, "secondary");
|
|
assert.equal(secondary.remote_publish_host, "global.example.com");
|
|
assert.equal(secondary.remote_publish_user, "deploy");
|
|
assert.equal(secondary.remote_publish_port, 22);
|
|
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
|
|
});
|
|
|
|
test("resolveAccount drops invalid remote_publish_port and strict_host_key_checking values", async (t) => {
|
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
|
|
|
useCwd(t, cwdRoot);
|
|
useHome(t, homeRoot);
|
|
useXdgConfigHome(t, undefined);
|
|
|
|
await writeExtendFile(
|
|
cwdRoot,
|
|
[
|
|
"remote_publish_host: example.com",
|
|
"remote_publish_port: 99999",
|
|
"remote_publish_connect_timeout: 0",
|
|
"remote_publish_strict_host_key_checking: maybe",
|
|
].join("\n"),
|
|
);
|
|
|
|
const config = loadWechatExtendConfig();
|
|
const resolved = resolveAccount(config);
|
|
assert.equal(resolved.remote_publish_host, "example.com");
|
|
assert.equal(resolved.remote_publish_port, undefined);
|
|
assert.equal(resolved.remote_publish_connect_timeout, undefined);
|
|
assert.equal(resolved.remote_publish_strict_host_key_checking, undefined);
|
|
});
|
|
|
|
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/,
|
|
);
|
|
});
|