1 | import * as wmill from "windmill-client"; |
2 | import { basename } from "node:path" |
3 | const util = require('util'); |
4 | const exec = util.promisify(require('child_process').exec); |
5 |
|
6 |
|
7 | export async function main(repo_url_resource_path: string, init: boolean = false) { |
8 | const cwd = process.cwd(); |
9 | process.env["HOME"] = "."; |
10 | console.log(`Cloning repo from resource`); |
11 | let repo_name; |
12 | try { |
13 | repo_name = await git_clone(repo_url_resource_path); |
14 | process.chdir(`${cwd}/${repo_name}`); |
15 | |
16 | try { |
17 | await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd()); |
18 | } catch (e) { |
19 | console.log(`Warning: Could not add safe.directory config: ${e}`); |
20 | } |
21 | console.log(`Attempting an empty push to repository ${repo_name}`); |
22 | await git_push(init); |
23 | console.log("Finished"); |
24 | } finally { |
25 | |
26 | if (repo_name) { |
27 | try { |
28 | await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", `${cwd}/${repo_name}`); |
29 | } catch (e) { |
30 | console.log(`Warning: Could not unset safe.directory config: ${e}`); |
31 | } |
32 | } |
33 | process.chdir(`${cwd}`); |
34 | } |
35 | } |
36 |
|
37 | async function git_clone(repo_resource_path: string): Promise<string> { |
38 | |
39 |
|
40 | const repo_resource = await wmill.getResource(repo_resource_path); |
41 |
|
42 | let repo_url = repo_resource.url |
43 |
|
44 | if (repo_resource.is_github_app) { |
45 | const token = await get_gh_app_token() |
46 | const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token); |
47 | repo_url = authRepoUrl; |
48 | } else if (!urlCarriesCredential(repo_resource.url)) { |
49 | |
50 | |
51 | |
52 | const token = await get_stored_git_token(); |
53 | if (token) { |
54 | console.log("Using the git credential stored for this repository"); |
55 | repo_url = prependTokenToGitUrl(repo_resource.url, token); |
56 | } |
57 | } |
58 |
|
59 | const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/); |
60 | if (azureMatch) { |
61 | console.log( |
62 | "Requires Azure DevOps service account access token, requesting..." |
63 | ); |
64 | const azureResource = await wmill.getResource(azureMatch.groups.url); |
65 | const response = await fetch( |
66 | `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`, |
67 | { |
68 | method: "POST", |
69 | body: new URLSearchParams({ |
70 | client_id: azureResource.azureClientId, |
71 | client_secret: azureResource.azureClientSecret, |
72 | grant_type: "client_credentials", |
73 | resource: "499b84ac-1321-427f-aa17-267ca6975798/.default", |
74 | }), |
75 | } |
76 | ); |
77 | const { access_token } = await response.json(); |
78 | repo_url = repo_url.replace(azureMatch[0], access_token); |
79 | } |
80 | const repo_name = basename(repo_url, ".git"); |
81 | await sh_run(4, "git", "clone", "--quiet", "--depth", "1", repo_url, repo_name); |
82 | return repo_name; |
83 | } |
84 | async function git_push(init: boolean = false) { |
85 | await sh_run(undefined, "git", "config", "user.email", process.env["WM_EMAIL"]) |
86 | await sh_run(undefined, "git", "config", "user.name", process.env["WM_USERNAME"]) |
87 |
|
88 | try { |
89 | await sh_run(undefined, "git", "push"); |
90 | } catch (error) { |
91 | if (init && error.toString().includes("src refspec") && error.toString().includes("does not match any")) { |
92 | console.log("Repository is empty (no commits/branches yet). This is expected for a new repository."); |
93 | console.log("Push test completed - repository access verified, but no content to push."); |
94 | return; |
95 | } |
96 | |
97 | throw error; |
98 | } |
99 | } |
100 |
|
101 | async function sh_run( |
102 | secret_position: number | undefined, |
103 | cmd: string, |
104 | ...args: string[] |
105 | ) { |
106 | const nargs = secret_position != undefined ? args.slice() : args; |
107 | if (secret_position && secret_position < 0) { |
108 | secret_position = nargs.length - 1 + secret_position; |
109 | } |
110 | let secret: string | undefined = undefined; |
111 | if (secret_position != undefined) { |
112 | nargs[secret_position] = "***"; |
113 | secret = args[secret_position]; |
114 | } |
115 |
|
116 | console.log(`Running '${cmd} ${nargs.join(" ")} ...'`); |
117 | const command = exec(`${cmd} ${args.join(" ")}`) |
118 | try { |
119 | const { stdout, stderr } = await command |
120 | if (stdout.length > 0) { |
121 | console.log(stdout); |
122 | } |
123 | if (stderr.length > 0) { |
124 | console.log(stderr); |
125 | } |
126 | console.log("Command successfully executed"); |
127 |
|
128 | } catch (error) { |
129 | let errorString = error.toString(); |
130 | if (secret) { |
131 | errorString = errorString.replace(secret, "***"); |
132 | } |
133 | const err = `SH command '${cmd} ${nargs.join( |
134 | " " |
135 | )}' returned with error ${errorString}`; |
136 | throw Error(err); |
137 | } |
138 | } |
139 |
|
140 | |
141 | |
142 | function urlCarriesCredential(url: string | undefined): boolean { |
143 | return /:\/\/[^/@]+@/.test(url ?? ""); |
144 | } |
145 |
|
146 | |
147 | |
148 | |
149 | async function get_stored_git_token() { |
150 | try { |
151 | return await get_gh_app_token(); |
152 | } catch (error) { |
153 | console.log( |
154 | "No stored git credential for this repository:", |
155 | error?.message ?? String(error) |
156 | ); |
157 | return undefined; |
158 | } |
159 | } |
160 |
|
161 | async function get_gh_app_token() { |
162 | const workspace = process.env["WM_WORKSPACE"]; |
163 | const jobToken = process.env["WM_TOKEN"]; |
164 |
|
165 | const baseUrl = |
166 | process.env["BASE_INTERNAL_URL"] ?? |
167 | process.env["BASE_URL"] ?? |
168 | "http://localhost:8000"; |
169 |
|
170 | const url = `${baseUrl}/api/w/${workspace}/github_app/token`; |
171 |
|
172 | const response = await fetch(url, { |
173 | method: 'POST', |
174 | headers: { |
175 | 'Content-Type': 'application/json', |
176 | 'Authorization': `Bearer ${jobToken}`, |
177 | }, |
178 | body: JSON.stringify({ |
179 | job_token: jobToken, |
180 | }), |
181 | }); |
182 |
|
183 | if (!response.ok) { |
184 | const errorBody = await response.text().catch(() => ""); |
185 | throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`); |
186 | } |
187 |
|
188 | const data = await response.json(); |
189 |
|
190 | return data.token; |
191 | } |
192 |
|
193 | |
194 | |
195 | |
196 | function normalizeGitHubUrl(raw: string): string { |
197 | const cleaned = raw.trim(); |
198 | if (cleaned.includes("://")) { |
199 | return cleaned; |
200 | } |
201 | const colonIdx = cleaned.indexOf(":"); |
202 | if (colonIdx !== -1) { |
203 | const before = cleaned.slice(0, colonIdx); |
204 | const after = cleaned.slice(colonIdx + 1); |
205 | const firstSeg = after.split("/")[0]; |
206 | |
207 | |
208 | const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg); |
209 | |
210 | |
211 | const isUserinfo = firstSeg.includes("@"); |
212 | if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) { |
213 | const host = before.split("@").pop(); |
214 | return `https://${host}/${after.replace(/^\/+/, "")}`; |
215 | } |
216 | } |
217 | return `https://${cleaned}`; |
218 | } |
219 |
|
220 | function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) { |
221 | if (!gitHubUrl || !installationToken) { |
222 | throw new Error("Both GitHub URL and Installation Token are required."); |
223 | } |
224 |
|
225 | |
226 | const url = new URL(normalizeGitHubUrl(gitHubUrl)); |
227 | return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`; |
228 | } |
229 |
|
230 | |
231 | |
232 | |
233 | |
234 | function prependTokenToGitUrl(gitUrl: string, token: string) { |
235 | const url = new URL(normalizeGitHubUrl(gitUrl)); |
236 | url.username = "oauth2"; |
237 | url.password = token; |
238 | return url.toString(); |
239 | } |
240 |
|