init: video-create project with skills and accounts

This commit is contained in:
2026-04-29 21:04:43 +08:00
commit dadddc7aec
64 changed files with 14715 additions and 0 deletions

View File

@@ -0,0 +1,457 @@
/**
* Kling AI — 鉴权层(无网络)
*
* 凭证优先级:
* 1. 当前进程 KLING_TOKEN仅环境变量显式传入不落盘
* 2. ~/.config/kling/.credentialsINI[profile] access_key_id / secret_access_key→ 请求时 makeJwt30min exp
* bind / configure 写入 credentials固定 default profile。
* 存储根目录默认 ~/.config/kling可选 KLING_STORAGE_ROOT 指向统一存储根。
* 非凭证 env仅读 <storageRoot>/kling.env不覆盖启动前已在 process.env 中的键。
* 探测得到的 API Base 由 client 调用 `persistProbedApiBase` 写回 ~/.config/kling/kling.env 中的 KLING_API_BASE
* **不会**从文件注入 KLING_TOKEN凭证仅 credentials 文件 + 可选进程内 KLING_TOKEN
*
* 网络与 API Base 探测统一在 client.mjs。
*/
import { createHmac, randomUUID } from 'node:crypto';
import {
readFileSync, writeFileSync, mkdirSync, chmodSync,
} from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import os from 'node:os';
const __dir = dirname(fileURLToPath(import.meta.url));
const KLING_ENV_FILENAME = 'kling.env';
const IDENTITY_FILENAME = 'identity.json';
const CREDENTIALS_FILENAME = '.credentials';
const STORAGE_ROOT_ENV = 'KLING_STORAGE_ROOT';
/** 写入 process.env 时跳过(凭证不走 dotenv 文件) */
const CREDENTIAL_ENV_DENYLIST = new Set(['KLING_TOKEN']);
/**
* @param {string} content
* @param {{ shellKeys: Set<string> }} opts
*/
function parseEnvContent(content, opts) {
const { shellKeys } = opts;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx <= 0) continue;
const key = trimmed.slice(0, eqIdx).trim();
if (CREDENTIAL_ENV_DENYLIST.has(key)) continue;
let val = trimmed.slice(eqIdx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
// 已在启动前导出的环境变量优先,不被文件覆盖。
if (!shellKeys.has(key) && !(key in process.env)) {
process.env[key] = val;
}
}
}
export function getKlingConfigDir() {
const explicitRoot = (process.env[STORAGE_ROOT_ENV] || '').trim();
if (explicitRoot) return resolve(explicitRoot);
const home = process.env.HOME || process.env.USERPROFILE;
if (home) return join(home, '.config', 'kling');
return resolve(__dir, '..', '..', '..');
}
function getDefaultKlingEnvPath() {
return join(getKlingConfigDir(), KLING_ENV_FILENAME);
}
/** 更新或追加 KLING_API_BASE=…,仅写入 ~/.config/kling/kling.env */
function upsertEnvFileKey(content, key, value) {
const line = `${key}=${value}`;
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`^${escaped}=.*$`, 'm');
if (re.test(content)) return content.replace(re, line);
const trimmed = content.replace(/\s+$/, '');
if (!trimmed) return `${line}\n`;
return `${trimmed}\n${line}\n`;
}
(function loadEnvFiles() {
const shellKeys = new Set(Object.keys(process.env));
try {
parseEnvContent(readFileSync(getDefaultKlingEnvPath(), 'utf-8'), { shellKeys });
} catch {}
})();
export function getIdentityFilePath() {
return join(getKlingConfigDir(), IDENTITY_FILENAME);
}
/** 凭证 INI 路径:<storageRoot>/.credentials */
export function getCredentialsFilePath() {
return join(getKlingConfigDir(), CREDENTIALS_FILENAME);
}
export function getActiveProfile() {
return 'default';
}
export class CredentialsMissingError extends Error {
constructor(msg = 'No credentials / 未配置凭证') {
super(msg);
this.name = 'CredentialsMissingError';
}
}
function logAuthSource(source) {
const messageMap = {
credentials: 'Auth source / 鉴权来源: credentials (AK/SK -> JWT)',
env_token: 'Auth source / 鉴权来源: KLING_TOKEN (process env)',
};
const msg = messageMap[source];
if (msg) console.error(msg);
}
function parseCredentialsIni(content) {
const profiles = {};
let current = null;
for (const line of content.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#') || t.startsWith(';')) continue;
const m = t.match(/^\[([^\]]+)\]\s*$/);
if (m) {
current = m[1].trim();
if (!profiles[current]) profiles[current] = {};
continue;
}
const eqIdx = t.indexOf('=');
if (eqIdx <= 0 || !current) continue;
const k = t.slice(0, eqIdx).trim();
let v = t.slice(eqIdx + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
profiles[current][k] = v;
}
return profiles;
}
/** @returns {{ access_key_id: string, secret_access_key: string }} */
export function readCredentialsProfile(profile) {
try {
const raw = readFileSync(getCredentialsFilePath(), 'utf-8');
const all = parseCredentialsIni(raw);
const p = all[profile] || {};
const ak = String(p.access_key_id || p.access_key || '').trim();
const sk = String(p.secret_access_key || p.secret_key || '').trim();
return { access_key_id: ak, secret_access_key: sk };
} catch {
return { access_key_id: '', secret_access_key: '' };
}
}
export function hasStoredAccessKeys() {
const { access_key_id, secret_access_key } = readCredentialsProfile(getActiveProfile());
return Boolean(access_key_id && secret_access_key);
}
export function hasSessionBearerOverride() {
return Boolean((process.env.KLING_TOKEN || '').trim());
}
export function hasUsableCredentialSource() {
return hasStoredAccessKeys() || hasSessionBearerOverride();
}
/**
* 写入 [profile] 下 AK/SKUnix 上 chmod 600
* @param {string} profile
* @param {string} accessKey
* @param {string} secretKey
* @param {Record<string,string>} [extra] 如 region
*/
export function writeCredentialsProfile(profile, accessKey, secretKey, extra = {}) {
const path = getCredentialsFilePath();
mkdirSync(dirname(path), { recursive: true });
let all = {};
try {
all = parseCredentialsIni(readFileSync(path, 'utf-8'));
} catch {}
all[profile] = {
...all[profile],
access_key_id: String(accessKey || '').trim(),
secret_access_key: String(secretKey || '').trim(),
...extra,
};
const lines = [];
for (const prof of Object.keys(all)) {
lines.push(`[${prof}]`);
const o = all[prof];
for (const [k, v] of Object.entries(o)) {
if (v == null || String(v) === '') continue;
lines.push(`${k} = ${String(v)}`);
}
lines.push('');
}
writeFileSync(path, lines.join('\n').trimEnd() + '\n');
try {
if (process.platform !== 'win32') chmodSync(path, 0o600);
} catch {}
return path;
}
// —— Skill 版本 / 请求头 ——
const DEFAULT_SKILL_VERSION = '1.0.0';
let skillVersion = DEFAULT_SKILL_VERSION;
export function setSkillVersion(version) {
skillVersion = String(version || DEFAULT_SKILL_VERSION);
}
export function getSkillVersion() {
return skillVersion;
}
export function makeKlingHeaders(token, contentType = 'application/json') {
const h = { 'User-Agent': `Kling-Provider-Skill/${getSkillVersion()}` };
if (token) h['Authorization'] = `Bearer ${token}`;
if (contentType) h['Content-Type'] = contentType;
return h;
}
function base64url(buf) {
return Buffer.from(buf).toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function makeJwt(accessKey, secretKey) {
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const payload = base64url(JSON.stringify({
iss: accessKey,
exp: now + 1800,
nbf: now - 5,
}));
const signature = base64url(
createHmac('sha256', secretKey).update(`${header}.${payload}`).digest()
);
return `${header}.${payload}.${signature}`;
}
/**
* 1) 进程环境变量 KLING_TOKEN不落盘kling.env 不会注入 KLING_TOKEN
* 2) 否则 credentials 文件 AK/SK → 每次调用重新签发 JWT30min exp
*/
export function getBearerToken() {
let token = (process.env.KLING_TOKEN || '').trim();
if (token) {
logAuthSource('env_token');
if (token.toLowerCase().startsWith('bearer ')) {
token = token.slice(7).trim();
}
return token;
}
const profile = getActiveProfile();
const { access_key_id, secret_access_key } = readCredentialsProfile(profile);
if (access_key_id && secret_access_key) {
logAuthSource('credentials');
return makeJwt(access_key_id, secret_access_key);
}
throw new CredentialsMissingError(
'Configure credentials under KLING_STORAGE_ROOT (or ~/.config/kling), set KLING_TOKEN for this session, or run account bind/configure / '
+ '请在 KLING_STORAGE_ROOT或 ~/.config/kling下配置 credentials、本次 shell 导出 KLING_TOKEN或执行 account --bind|--configure',
);
}
export function getConfiguredApiBase() {
const baseTest = (process.env.KLING_API_BASE_TEST || '').trim();
if (baseTest) return baseTest;
const base = (process.env.KLING_API_BASE || '').trim();
return base || null;
}
export function getConfiguredBindBase() {
const baseTest = (process.env.KLING_BIND_BASE_TEST || '').trim();
if (baseTest) return baseTest;
const base = (process.env.KLING_BIND_BASE || '').trim();
return base || null;
}
/** 将探测到的业务 API 根写入 ~/.config/kling/kling.env仅 KLING_API_BASE 一行) */
export function persistProbedApiBase(baseUrl) {
const b = String(baseUrl || '').trim();
if (!b) return;
const dir = getKlingConfigDir();
const path = getDefaultKlingEnvPath();
mkdirSync(dir, { recursive: true });
let raw = '';
try {
raw = readFileSync(path, 'utf-8');
} catch {}
writeFileSync(path, upsertEnvFileKey(raw, 'KLING_API_BASE', b));
process.env.KLING_API_BASE = b;
}
export function readIdentity() {
try {
const raw = readFileSync(getIdentityFilePath(), 'utf-8');
const o = JSON.parse(raw);
return o && typeof o === 'object' ? o : null;
} catch {
return null;
}
}
function writeIdentity(obj) {
const dir = getKlingConfigDir();
mkdirSync(dir, { recursive: true });
writeFileSync(getIdentityFilePath(), `${JSON.stringify(obj, null, 2)}\n`);
}
export function ensureIdentityForBind() {
const existing = readIdentity() || {};
const id = { ...existing };
let dirty = Object.keys(existing).length === 0;
if (!id.client_instance_id) {
id.client_instance_id = randomUUID();
dirty = true;
}
const localHostname = (() => {
try {
const h = String(os.hostname() || '').trim();
return h || 'unknown';
} catch {
return 'unknown';
}
})();
if (!id.hostname) {
id.hostname = localHostname;
dirty = true;
}
if (!id.device_name) {
const n = String(process.env.COMPUTERNAME || process.env.HOSTNAME || id.hostname || '').trim();
id.device_name = n || 'unknown';
dirty = true;
}
if (!id.platform) {
if (process.platform === 'darwin') id.platform = 'macOS';
else if (process.platform === 'win32') id.platform = 'Windows';
else if (process.platform === 'linux') id.platform = 'Linux';
else id.platform = 'unknown';
dirty = true;
}
id.version = id.version ?? 1;
if (id.session_id === undefined) id.session_id = null;
id.updated_at = Date.now();
if (dirty) writeIdentity(id);
return id;
}
export function patchKlingIdentity(patch) {
const cur = readIdentity() || {};
const next = { ...cur, ...patch, updated_at: Date.now() };
writeIdentity(next);
return next;
}
/** 绑定 / configure 成功后写入 credentialsidentity 中不保留 AK/SK并清除历史字段 */
export function persistBoundApiKeys(accessKey, secretKey, extraIdentity = {}, extraCredentials = {}) {
const ak = String(accessKey || '').trim();
const sk = String(secretKey || '').trim();
if (!ak || !sk) throw new Error('Missing access_key or secret_key / 缺少 access_key 或 secret_key');
const profile = getActiveProfile();
const savePath = writeCredentialsProfile(profile, ak, sk, extraCredentials);
const cur = readIdentity() || {};
const next = { ...cur, ...extraIdentity, bound_at: Date.now(), updated_at: Date.now() };
delete next.access_key;
delete next.secret_key;
delete next.credential_id;
delete next.account_id;
delete next.credentialId;
delete next.accountId;
writeIdentity(next);
return { savePath, token: makeJwt(ak, sk) };
}
export { makeJwt };
function readHiddenLine(prompt) {
function sanitizeChunk(chunk) {
// Strip bracketed-paste markers (\x1b[200~...\x1b[201~), keep printable chars only.
return String(chunk || '')
.replace(/\u001b\[200~/g, '')
.replace(/\u001b\[201~/g, '')
.replace(/[\u0000-\u001f\u007f]/g, '');
}
const stdin = process.stdin;
const stdout = process.stderr;
if (!stdin.isTTY) {
return new Promise((r) => {
const rl = createInterface({ input: stdin, output: stdout });
rl.question(prompt, (a) => {
rl.close();
r(a.trim());
});
});
}
stdout.write(prompt);
return new Promise((resolveLine) => {
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
let s = '';
const onData = (key) => {
const k = String(key);
if (k === '\u0003') {
stdin.setRawMode(false);
stdin.removeListener('data', onData);
stdin.pause();
process.exit(1);
}
if (k === '\r' || k === '\n') {
stdin.setRawMode(false);
stdin.removeListener('data', onData);
stdin.pause();
stdout.write('\n');
resolveLine(s);
return;
}
if (k === '\u007f' || k === '\b') {
s = s.slice(0, -1);
return;
}
s += sanitizeChunk(k);
};
stdin.on('data', onData);
});
}
/** 交互式录入 AK/SK → credentialsSK 在 TTY 下隐藏输入,支持粘贴) */
export async function promptInteractiveCredentialsFile() {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new CredentialsMissingError(
'TTY required / 需要交互式终端',
);
}
console.error('\n── Kling AI configure / 可灵凭证配置 ─────────────');
console.error(`Profile / 配置名: ${getActiveProfile()}`);
console.error(`File / 文件: ${getCredentialsFilePath()}`);
console.error('────────────────────────────────────────────────\n');
const rl1 = createInterface({ input: process.stdin, output: process.stderr });
const accessKey = await new Promise((r) => {
rl1.question('Access Key ID / 访问密钥 ID: ', (a) => r(a.trim()));
});
rl1.close();
if (!accessKey) throw new Error('Access Key required / 需要 Access Key');
const secretKey = await readHiddenLine('Secret Access Key / 秘密访问密钥(隐藏输入,可粘贴): ');
if (!secretKey) throw new Error('Secret Key required / 需要 Secret Key');
const savePath = writeCredentialsProfile(getActiveProfile(), accessKey, secretKey);
console.error(`\n✓ Saved / 已保存(密钥未在日志中输出): ${savePath}\n`);
return makeJwt(accessKey, secretKey);
}