init: video-create project with skills and accounts
This commit is contained in:
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Kling AI — 鉴权层(无网络)
|
||||
*
|
||||
* 凭证优先级:
|
||||
* 1. 当前进程 KLING_TOKEN(仅环境变量显式传入,不落盘)
|
||||
* 2. ~/.config/kling/.credentials(INI,[profile] access_key_id / secret_access_key)→ 请求时 makeJwt(30min 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/SK,Unix 上 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 → 每次调用重新签发 JWT(30min 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 成功后写入 credentials;identity 中不保留 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 → credentials(SK 在 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);
|
||||
}
|
||||
Reference in New Issue
Block a user