chore: 增加 v0 与 Vercel 自动绑定部署闭环
This commit is contained in:
259
sdk/scripts/bind_v0_vercel.mjs
Normal file
259
sdk/scripts/bind_v0_vercel.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user