deepdebug-local-agent 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +32 -16
- package/cloudbuild-agent-qa.yaml +43 -0
- package/docker-compose.yml +2 -2
- package/package.json +1 -1
- package/src/exec-utils.js +126 -23
- package/src/server.js +563 -45
- package/src/vercel-proxy.js +226 -0
- package/tunnel-manager.js +70 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vercel-proxy.js
|
|
3
|
+
*
|
|
4
|
+
* Proxy module for Vercel API calls from the Local Agent.
|
|
5
|
+
* The Gateway (Cloud Run) cannot reach api.vercel.com directly due to egress rules.
|
|
6
|
+
* All Vercel API calls are routed through the Agent which has unrestricted outbound access.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints registered in server.js:
|
|
9
|
+
* POST /vercel/projects/ensure - Create project if not exists, return projectId
|
|
10
|
+
* POST /vercel/deploy - Trigger a deployment, return { deploymentId, previewUrl }
|
|
11
|
+
* GET /vercel/deploy/:id/status - Poll deployment status
|
|
12
|
+
* GET /vercel/projects - List all projects
|
|
13
|
+
*
|
|
14
|
+
* All endpoints require { token } in body or query param.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const VERCEL_API = "https://api.vercel.com";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Make an authenticated request to the Vercel API.
|
|
21
|
+
*/
|
|
22
|
+
async function vercelRequest(method, path, token, body = null) {
|
|
23
|
+
const opts = {
|
|
24
|
+
method,
|
|
25
|
+
headers: {
|
|
26
|
+
"Authorization": `Bearer ${token}`,
|
|
27
|
+
"Content-Type": "application/json"
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
if (body) opts.body = JSON.stringify(body);
|
|
31
|
+
|
|
32
|
+
const res = await fetch(`${VERCEL_API}${path}`, opts);
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
data = { raw: text };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const errMsg = data?.error?.message || data?.message || text;
|
|
44
|
+
throw new Error(`Vercel API ${method} ${path} -> ${res.status}: ${errMsg}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure a Vercel project exists.
|
|
52
|
+
* Creates it if missing, returns the project ID.
|
|
53
|
+
*/
|
|
54
|
+
async function ensureProject(token, projectName, repoOwner, repoName, framework) {
|
|
55
|
+
// Try to get existing project
|
|
56
|
+
try {
|
|
57
|
+
const existing = await vercelRequest("GET", `/v9/projects/${projectName}`, token);
|
|
58
|
+
console.log(`[Vercel] Project exists: ${projectName} (${existing.id})`);
|
|
59
|
+
return existing.id;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!err.message.includes("404")) throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create project
|
|
65
|
+
console.log(`[Vercel] Creating project: ${projectName}`);
|
|
66
|
+
const body = {
|
|
67
|
+
name: projectName,
|
|
68
|
+
gitRepository: {
|
|
69
|
+
type: "github",
|
|
70
|
+
repo: `${repoOwner}/${repoName}`
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
if (framework) body.framework = framework;
|
|
74
|
+
|
|
75
|
+
const created = await vercelRequest("POST", "/v9/projects", token, body);
|
|
76
|
+
console.log(`[Vercel] Project created: ${projectName} (${created.id})`);
|
|
77
|
+
return created.id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Trigger a deployment for a project.
|
|
82
|
+
*/
|
|
83
|
+
async function triggerDeploy(token, projectId, projectName, repoId, branch) {
|
|
84
|
+
console.log(`[Vercel] Triggering deploy: project=${projectName} branch=${branch}`);
|
|
85
|
+
|
|
86
|
+
const body = {
|
|
87
|
+
name: projectName,
|
|
88
|
+
target: "preview",
|
|
89
|
+
gitSource: {
|
|
90
|
+
type: "github",
|
|
91
|
+
repoId: String(repoId),
|
|
92
|
+
ref: branch || "main"
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const deployment = await vercelRequest("POST", "/v13/deployments", token, body);
|
|
97
|
+
|
|
98
|
+
const previewUrl = `https://${deployment.url}`;
|
|
99
|
+
console.log(`[Vercel] Deploy triggered: ${deployment.id} -> ${previewUrl}`);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
deploymentId: deployment.id,
|
|
103
|
+
previewUrl,
|
|
104
|
+
status: deployment.readyState || "INITIALIZING",
|
|
105
|
+
projectId,
|
|
106
|
+
projectName
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get deployment status.
|
|
112
|
+
*/
|
|
113
|
+
async function getDeployStatus(token, deploymentId) {
|
|
114
|
+
const d = await vercelRequest("GET", `/v13/deployments/${deploymentId}`, token);
|
|
115
|
+
return {
|
|
116
|
+
deploymentId: d.id,
|
|
117
|
+
status: d.readyState,
|
|
118
|
+
previewUrl: `https://${d.url}`,
|
|
119
|
+
errorMessage: d.errorMessage || null,
|
|
120
|
+
ready: d.readyState === "READY",
|
|
121
|
+
failed: d.readyState === "ERROR" || d.readyState === "CANCELED"
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List all projects.
|
|
127
|
+
*/
|
|
128
|
+
async function listProjects(token) {
|
|
129
|
+
const data = await vercelRequest("GET", "/v9/projects?limit=20", token);
|
|
130
|
+
return (data.projects || []).map(p => ({
|
|
131
|
+
id: p.id,
|
|
132
|
+
name: p.name,
|
|
133
|
+
framework: p.framework,
|
|
134
|
+
updatedAt: p.updatedAt,
|
|
135
|
+
latestDeploy: p.latestDeployments?.[0]?.url
|
|
136
|
+
? `https://${p.latestDeployments[0].url}`
|
|
137
|
+
: null
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Register Vercel proxy routes on the Express app.
|
|
143
|
+
*
|
|
144
|
+
* @param {import('express').Application} app
|
|
145
|
+
*/
|
|
146
|
+
export function registerVercelRoutes(app) {
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* POST /vercel/projects/ensure
|
|
150
|
+
* Body: { token, projectName, repoOwner, repoName, framework? }
|
|
151
|
+
* Response: { ok, projectId }
|
|
152
|
+
*/
|
|
153
|
+
app.post("/vercel/projects/ensure", async (req, res) => {
|
|
154
|
+
const { token, projectName, repoOwner, repoName, framework } = req.body || {};
|
|
155
|
+
if (!token || !projectName || !repoOwner || !repoName) {
|
|
156
|
+
return res.status(400).json({ ok: false, error: "token, projectName, repoOwner, repoName required" });
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const projectId = await ensureProject(token, projectName, repoOwner, repoName, framework);
|
|
160
|
+
return res.json({ ok: true, projectId, projectName });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`[Vercel] ensureProject failed: ${err.message}`);
|
|
163
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* POST /vercel/deploy
|
|
169
|
+
* Body: { token, projectId, projectName, repoOwner, repoName, branch? }
|
|
170
|
+
* Note: repoId is fetched automatically from GitHub API if not provided.
|
|
171
|
+
* Response: { ok, deploymentId, previewUrl, status }
|
|
172
|
+
*/
|
|
173
|
+
app.post("/vercel/deploy", async (req, res) => {
|
|
174
|
+
const { token, projectId, projectName, repoOwner, repoName, branch } = req.body || {};
|
|
175
|
+
if (!token || !projectId || !projectName || !repoOwner || !repoName) {
|
|
176
|
+
return res.status(400).json({ ok: false, error: "token, projectId, projectName, repoOwner, repoName required" });
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
// Fetch repoId from GitHub API
|
|
180
|
+
const ghRes = await fetch(`https://api.github.com/repos/${repoOwner}/${repoName}`);
|
|
181
|
+
if (!ghRes.ok) throw new Error(`GitHub repo not found: ${repoOwner}/${repoName}`);
|
|
182
|
+
const ghData = await ghRes.json();
|
|
183
|
+
const repoId = ghData.id;
|
|
184
|
+
const result = await triggerDeploy(token, projectId, projectName, repoId, branch);
|
|
185
|
+
return res.json({ ok: true, ...result });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(`[Vercel] deploy failed: ${err.message}`);
|
|
188
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* GET /vercel/deploy/:deploymentId/status?token=xxx
|
|
194
|
+
* Response: { ok, deploymentId, status, previewUrl, ready, failed }
|
|
195
|
+
*/
|
|
196
|
+
app.get("/vercel/deploy/:deploymentId/status", async (req, res) => {
|
|
197
|
+
const { deploymentId } = req.params;
|
|
198
|
+
const token = req.query.token;
|
|
199
|
+
if (!token) return res.status(400).json({ ok: false, error: "token query param required" });
|
|
200
|
+
try {
|
|
201
|
+
const result = await getDeployStatus(token, deploymentId);
|
|
202
|
+
return res.json({ ok: true, ...result });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(`[Vercel] status check failed: ${err.message}`);
|
|
205
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* GET /vercel/projects?token=xxx
|
|
211
|
+
* Response: { ok, projects[] }
|
|
212
|
+
*/
|
|
213
|
+
app.get("/vercel/projects", async (req, res) => {
|
|
214
|
+
const token = req.query.token;
|
|
215
|
+
if (!token) return res.status(400).json({ ok: false, error: "token query param required" });
|
|
216
|
+
try {
|
|
217
|
+
const projects = await listProjects(token);
|
|
218
|
+
return res.json({ ok: true, projects });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(`[Vercel] listProjects failed: ${err.message}`);
|
|
221
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
console.log("[Vercel] Proxy routes registered: /vercel/projects/ensure, /vercel/deploy, /vercel/deploy/:id/status, /vercel/projects");
|
|
226
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const activeTunnels = new Map();
|
|
3
|
+
|
|
4
|
+
async function startTunnel(sessionId, port) {
|
|
5
|
+
// If tunnel already exists for this session, return existing URL
|
|
6
|
+
const existing = activeTunnels.get(sessionId);
|
|
7
|
+
if (existing && existing.url) {
|
|
8
|
+
return existing.url;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
13
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
let tunnelUrl = null;
|
|
17
|
+
|
|
18
|
+
const timeout = setTimeout(() => {
|
|
19
|
+
if (!tunnelUrl) {
|
|
20
|
+
proc.kill();
|
|
21
|
+
reject(new Error('Tunnel startup timeout after 30s'));
|
|
22
|
+
}
|
|
23
|
+
}, 30000);
|
|
24
|
+
|
|
25
|
+
const onData = (data) => {
|
|
26
|
+
const match = data.toString().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
27
|
+
if (match && !tunnelUrl) {
|
|
28
|
+
tunnelUrl = match[0];
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
activeTunnels.set(sessionId, { process: proc, url: tunnelUrl, port });
|
|
31
|
+
console.log(`[Tunnel] Ready: ${tunnelUrl} (session: ${sessionId})`);
|
|
32
|
+
resolve(tunnelUrl);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
proc.stdout.on('data', onData);
|
|
37
|
+
proc.stderr.on('data', onData);
|
|
38
|
+
|
|
39
|
+
proc.on('exit', (code) => {
|
|
40
|
+
activeTunnels.delete(sessionId);
|
|
41
|
+
console.log(`[Tunnel] Process exited for session ${sessionId} (code: ${code})`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
proc.on('error', (err) => {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
reject(err);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stopTunnel(sessionId) {
|
|
52
|
+
const tunnel = activeTunnels.get(sessionId);
|
|
53
|
+
if (tunnel) {
|
|
54
|
+
tunnel.process.kill('SIGTERM');
|
|
55
|
+
activeTunnels.delete(sessionId);
|
|
56
|
+
console.log(`[Tunnel] Stopped: ${sessionId}`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getTunnelInfo(sessionId) {
|
|
63
|
+
return activeTunnels.get(sessionId) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getActiveTunnelCount() {
|
|
67
|
+
return activeTunnels.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { startTunnel, stopTunnel, getTunnelInfo, getActiveTunnelCount };
|