chore: 增加 v0 与 Vercel 自动绑定部署闭环

This commit is contained in:
Manus AI
2026-07-24 18:22:44 +08:00
parent 4c1176f5c1
commit 67a134feb9

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const ROOT = path.resolve(import.meta.dirname, "..", "..");
const VERCEL_API = "https://api.vercel.com";
const V0_API = "https://api.v0.dev";
const PROJECT_NAME = "workphone-console";
const GITHUB_REPO = "fnvtk/workphone-sdk";
const V0_PROJECT_ID = "3vTFXIHvZVu";
const V0_CHAT_ID = "llvv3dzaeNB";
function readEnvFile(file) {
if (!fs.existsSync(file)) return {};
return Object.fromEntries(
fs
.readFileSync(file, "utf8")
.split(/\r?\n/)
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/))
.filter(Boolean)
.map((match) => [
match[1],
match[2].replace(/^["']|["']$/g, ""),
]),
);
}
function loadCredentials() {
const sources = [
readEnvFile(path.join(ROOT, ".env")),
readEnvFile(
"/Users/karuo/Documents/开发/3、自营项目/上帝之眼/.env",
),
process.env,
];
return Object.assign({}, ...sources);
}
async function request(base, pathname, token, options = {}) {
const response = await fetch(`${base}${pathname}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers,
},
});
const text = await response.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text.slice(0, 500) };
}
if (!response.ok) {
const error = new Error(
`${options.method || "GET"} ${pathname}: ${response.status}`,
);
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
async function getOrCreateVercelProject(token) {
try {
return await request(
VERCEL_API,
`/v9/projects/${PROJECT_NAME}`,
token,
);
} catch (error) {
if (error.status !== 404) throw error;
}
return request(VERCEL_API, "/v11/projects", token, {
method: "POST",
body: JSON.stringify({
name: PROJECT_NAME,
framework: "vite",
gitRepository: {
type: "github",
repo: GITHUB_REPO,
},
rootDirectory: ".",
installCommand: "npm ci --prefix sdk/android-ui",
buildCommand: "npm run build --prefix sdk/android-ui",
outputDirectory: "sdk/android-ui/dist",
}),
});
}
async function configureVercelProject(token) {
return request(
VERCEL_API,
`/v9/projects/${PROJECT_NAME}`,
token,
{
method: "PATCH",
body: JSON.stringify({
framework: "vite",
rootDirectory: ".",
installCommand: "npm ci --prefix sdk/android-ui",
buildCommand: "npm run build --prefix sdk/android-ui",
outputDirectory: "sdk/android-ui/dist",
ssoProtection: { deploymentType: "preview" },
}),
},
);
}
async function deploy(token) {
return request(
VERCEL_API,
"/v13/deployments?skipAutoDetectionConfirmation=1",
token,
{
method: "POST",
body: JSON.stringify({
name: PROJECT_NAME,
target: "production",
gitSource: {
type: "github",
org: "fnvtk",
repo: "workphone-sdk",
ref: "main",
},
projectSettings: {
framework: "vite",
rootDirectory: ".",
installCommand: "npm ci --prefix sdk/android-ui",
buildCommand: "npm run build --prefix sdk/android-ui",
outputDirectory: "sdk/android-ui/dist",
},
}),
},
);
}
async function waitForDeployment(token, deploymentId) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const deployment = await request(
VERCEL_API,
`/v13/deployments/${deploymentId}`,
token,
);
if (deployment.readyState === "READY") return deployment;
if (["ERROR", "CANCELED"].includes(deployment.readyState)) {
throw new Error(`deployment_${deployment.readyState.toLowerCase()}`);
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error("deployment_timeout");
}
async function bindV0(v0Token, vercelProjectId) {
const project = await request(
V0_API,
`/v1/projects/${V0_PROJECT_ID}`,
v0Token,
);
if (project.vercelProjectId === vercelProjectId) return project;
try {
return await request(
V0_API,
`/v1/projects/${V0_PROJECT_ID}`,
v0Token,
{
method: "PATCH",
body: JSON.stringify({ vercelProjectId }),
},
);
} catch (error) {
if (![404, 405, 422].includes(error.status)) throw error;
return request(V0_API, "/v1/projects", v0Token, {
method: "POST",
body: JSON.stringify({
name: "工作手机控制台",
description: "工作手机前端GitHub main 为唯一源码真源",
vercelProjectId,
}),
});
}
}
async function triggerV0Deployment(v0Token) {
const chat = await request(
V0_API,
`/v1/chats/${V0_CHAT_ID}`,
v0Token,
);
return request(V0_API, "/v1/deployments", v0Token, {
method: "POST",
body: JSON.stringify({
projectId: V0_PROJECT_ID,
chatId: V0_CHAT_ID,
versionId: chat.latestVersion.id,
}),
});
}
async function main() {
const credentials = loadCredentials();
if (!credentials.VERCEL_TOKEN) throw new Error("missing_vercel_token");
if (!credentials.V0_API_KEY) throw new Error("missing_v0_api_key");
await request(VERCEL_API, "/v2/user", credentials.VERCEL_TOKEN);
const project = await getOrCreateVercelProject(credentials.VERCEL_TOKEN);
await configureVercelProject(credentials.VERCEL_TOKEN);
const deployment = await deploy(credentials.VERCEL_TOKEN);
const ready = await waitForDeployment(
credentials.VERCEL_TOKEN,
deployment.id,
);
const v0Project = await bindV0(credentials.V0_API_KEY, project.id);
const v0Deployment = await triggerV0Deployment(credentials.V0_API_KEY);
const productionUrl = `https://${ready.alias?.[0] || ready.url}`;
const check = await fetch(productionUrl, { redirect: "follow" });
if (!check.ok) throw new Error(`production_http_${check.status}`);
console.log(
JSON.stringify(
{
github: GITHUB_REPO,
branch: "main",
vercelProjectId: project.id,
deploymentId: ready.id,
productionUrl,
productionHttpStatus: check.status,
v0ProjectId: v0Project.id,
v0ChatId: V0_CHAT_ID,
v0DeploymentId: v0Deployment.id,
status: "completed",
},
null,
2,
),
);
}
main().catch((error) => {
console.error(
JSON.stringify(
{
status: "failed",
error: error.message,
detail: error.data?.error || error.data || null,
},
null,
2,
),
);
process.exit(1);
});