-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathselfUpdate.ts
More file actions
367 lines (320 loc) · 11.8 KB
/
selfUpdate.ts
File metadata and controls
367 lines (320 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import crypto from 'crypto';
import semver from 'semver';
import { execa } from 'execa';
import { logger } from './logger.js';
import { writeConfig } from './config.js';
const REPO_OWNER = 'Kinin-Code-Offical';
const REPO_NAME = 'cloudsqlctl';
const GITHUB_API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
const GITHUB_RELEASES_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
const GITHUB_RELEASE_TAG_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags`;
const TIMEOUT_MS = 60000;
const MAX_RETRIES = 2;
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
export interface ReleaseInfo {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
body: string;
prerelease: boolean;
draft: boolean;
}
export interface UpdateStatus {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseInfo?: ReleaseInfo;
}
function getGithubHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'User-Agent': 'cloudsqlctl/upgrade' };
const token = process.env.CLOUDSQLCTL_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function getRateLimitMessage(error: unknown): string | null {
if (axios.isAxiosError(error) && error.response) {
const remaining = error.response.headers['x-ratelimit-remaining'];
const reset = error.response.headers['x-ratelimit-reset'];
if (remaining === '0' && reset) {
const resetTime = new Date(Number(reset) * 1000).toISOString();
return `GitHub API rate limit exceeded. Resets at ${resetTime}.`;
}
}
return null;
}
function shouldRetry(error: unknown): boolean {
if (axios.isAxiosError(error)) {
if (!error.response) return true;
return RETRYABLE_STATUS.has(error.response.status);
}
return false;
}
async function githubGet<T>(url: string) {
let attempt = 0;
while (attempt <= MAX_RETRIES) {
try {
return await axios.get<T>(url, {
timeout: TIMEOUT_MS,
headers: getGithubHeaders()
});
} catch (error) {
attempt++;
const rateLimit = getRateLimitMessage(error);
if (rateLimit) {
logger.warn(rateLimit);
}
if (attempt > MAX_RETRIES || !shouldRetry(error)) {
throw error;
}
const delayMs = 1000 * attempt;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('GitHub API request failed after retries');
}
export async function getLatestRelease(): Promise<ReleaseInfo> {
try {
const response = await githubGet<ReleaseInfo>(GITHUB_API_URL);
return response.data;
} catch (error) {
logger.error('Failed to fetch latest release info', error);
throw error;
}
}
function normalizeTag(tag: string): string {
return tag.startsWith('v') ? tag : `v${tag}`;
}
export async function getReleaseByTag(tag: string): Promise<ReleaseInfo> {
try {
const response = await githubGet<ReleaseInfo>(`${GITHUB_RELEASE_TAG_URL}/${normalizeTag(tag)}`);
return response.data;
} catch (error) {
logger.error('Failed to fetch release by tag', error);
throw error;
}
}
export async function getLatestPrerelease(): Promise<ReleaseInfo> {
try {
const response = await githubGet<ReleaseInfo[]>(GITHUB_RELEASES_URL);
const releases = Array.isArray(response.data) ? response.data : [];
const prerelease = releases.find(r => r.prerelease && !r.draft);
if (!prerelease) {
throw new Error('No prerelease found');
}
return prerelease;
} catch (error) {
logger.error('Failed to fetch latest prerelease info', error);
throw error;
}
}
export async function checkForUpdates(
currentVersion: string,
options: { channel?: 'stable' | 'beta'; targetVersion?: string } = {}
): Promise<UpdateStatus> {
let release: ReleaseInfo;
if (options.targetVersion) {
release = await getReleaseByTag(options.targetVersion);
} else if (options.channel === 'beta') {
release = await getLatestPrerelease();
} else {
release = await getLatestRelease();
}
// Remove 'v' prefix if present for semver comparison
const latestVer = release.tag_name.replace(/^v/, '');
const currentVer = currentVersion.replace(/^v/, '');
const updateAvailable = semver.gt(latestVer, currentVer);
// Update config with check info
try {
await writeConfig({
lastUpdateCheck: new Date().toISOString(),
lastUpdateAvailableVersion: updateAvailable ? release.tag_name : undefined
});
} catch (_e) {
// Ignore config write errors during check
}
return {
currentVersion,
latestVersion: release.tag_name,
updateAvailable,
releaseInfo: release
};
}
export async function fetchSha256Sums(release: ReleaseInfo): Promise<Map<string, string>> {
const checksumAsset = release.assets.find(a => a.name === 'SHA256SUMS.txt');
if (!checksumAsset) {
throw new Error('SHA256SUMS.txt not found in release assets');
}
const response = await axios.get(checksumAsset.browser_download_url, {
responseType: 'text',
timeout: TIMEOUT_MS,
headers: getGithubHeaders()
});
const sums = new Map<string, string>();
const lines = response.data.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2) {
// Format: <hash> <filename>
sums.set(parts[1], parts[0]);
}
}
return sums;
}
export async function downloadFile(url: string, destPath: string): Promise<void> {
await fs.ensureDir(path.dirname(destPath));
let attempt = 0;
while (attempt <= MAX_RETRIES) {
try {
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
timeout: TIMEOUT_MS,
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
});
const writer = fs.createWriteStream(destPath);
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', () => resolve());
writer.on('error', reject);
});
return;
} catch (error) {
attempt++;
if (attempt > MAX_RETRIES) throw error;
logger.warn(`Download failed, retrying... (${error})`);
}
}
}
export async function verifySha256(filePath: string, expectedHash: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', err => reject(err));
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => {
const fileHash = hash.digest('hex');
resolve(fileHash.toLowerCase() === expectedHash.toLowerCase());
});
});
}
export function detectInstallContext(): 'installer' | 'portable' {
const { execPath } = process;
// If running from source (node.exe), default to portable logic but likely won't work well
if (path.basename(execPath).toLowerCase() === 'node.exe') {
return 'portable';
}
// Check if running from Program Files
if (execPath.includes('Program Files')) {
return 'installer';
}
return 'portable';
}
export function pickAsset(release: ReleaseInfo, mode: 'auto' | 'installer' | 'exe'): { name: string, url: string } {
const context = mode === 'auto' ? detectInstallContext() : mode;
let assetNamePattern: RegExp;
if (context === 'installer') {
assetNamePattern = /cloudsqlctl-setup\.exe$/i;
} else {
assetNamePattern = /cloudsqlctl\.exe$/i;
}
const asset = release.assets.find(a => assetNamePattern.test(a.name));
if (!asset) {
throw new Error(`Could not find suitable asset for mode '${context}' (pattern: ${assetNamePattern})`);
}
return { name: asset.name, url: asset.browser_download_url };
}
export async function applyUpdateInstaller(installerPath: string, silent: boolean, elevate: boolean) {
logger.info('Launching installer...');
const args: string[] = [];
if (silent) {
args.push('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART');
}
// Use PowerShell Start-Process with environment variables for both elevated and non-elevated runs
const envVars: Record<string, string> = {
'PS_INSTALLER_PATH': installerPath,
'PS_INSTALLER_ARGS': args.join(' ')
};
const basePsCommand = `
$p = [System.Environment]::GetEnvironmentVariable('PS_INSTALLER_PATH')
$a = [System.Environment]::GetEnvironmentVariable('PS_INSTALLER_ARGS')
`.trim();
if (elevate) {
// Elevated: use -Verb RunAs
const psCommand = `
${basePsCommand}
Start-Process -FilePath $p -ArgumentList $a -Verb RunAs -Wait
`.trim();
await execa('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCommand], {
env: { ...process.env, ...envVars }
});
} else {
// Non-elevated: run without -Verb RunAs
const psCommand = `
${basePsCommand}
Start-Process -FilePath $p -ArgumentList $a -Wait
`.trim();
await execa('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCommand], {
env: { ...process.env, ...envVars }
});
}
}
export async function applyUpdatePortableExe(newExePath: string, targetExePath: string) {
logger.info('Applying portable update...');
const updateScriptPath = path.join(path.dirname(targetExePath), 'apply-update.ps1');
const backupPath = `${targetExePath}.bak`;
const { pid } = process;
// PowerShell script to wait for PID exit, swap files, and restart
const psScript = `
param($PidToWait, $NewExe, $TargetExe, $BackupExe)
Write-Host "Waiting for process $PidToWait to exit..."
try {
$p = Get-Process -Id $PidToWait -ErrorAction SilentlyContinue
if ($p) { $p.WaitForExit() }
} catch {}
Write-Host "Staging new binary..."
$TempExe = "$TargetExe.tmp"
Copy-Item -Path $NewExe -Destination $TempExe -Force
Write-Host "Swapping binaries..."
try {
Move-Item -Path $TargetExe -Destination $BackupExe -Force -ErrorAction SilentlyContinue
Move-Item -Path $TempExe -Destination $TargetExe -Force
Remove-Item -Path $BackupExe -Force -ErrorAction SilentlyContinue
} catch {
Write-Host "Swap failed, attempting rollback..."
if (Test-Path $BackupExe) {
Move-Item -Path $BackupExe -Destination $TargetExe -Force -ErrorAction SilentlyContinue
}
if (Test-Path $TempExe) {
Remove-Item -Path $TempExe -Force -ErrorAction SilentlyContinue
}
throw
}
Write-Host "Starting new version..."
Start-Process -FilePath $TargetExe -ArgumentList "--version"
`;
await fs.writeFile(updateScriptPath, psScript);
// Spawn detached PowerShell process
const child = execa('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', updateScriptPath,
'-PidToWait', pid.toString(),
'-NewExe', newExePath,
'-TargetExe', targetExePath,
'-BackupExe', backupPath
], {
detached: true,
stdio: 'ignore'
});
child.unref();
logger.info('Update script spawned. Exiting to allow update to proceed.');
process.exit(0);
}