@pablozaiden/installer 0.0.0-development

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/src/update.ts ADDED
@@ -0,0 +1,502 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdtemp, realpath, rename, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import {
5
+ DEFAULT_CHECKSUM_EXTENSION,
6
+ DEFAULT_GITHUB_API_VERSION,
7
+ assertGitHubRepository,
8
+ buildReleaseAssetName,
9
+ compareReleaseVersions,
10
+ githubApiRepositoryUrl,
11
+ normalizeReleaseTag,
12
+ normalizeReleaseVersion,
13
+ resolveReleasePlatform,
14
+ type GitHubRepository,
15
+ type ReleasePlatform,
16
+ } from "./contract";
17
+
18
+ export type UpdateCommandOptions = {
19
+ checkOnly: boolean;
20
+ version?: string;
21
+ };
22
+
23
+ export type UpdaterChecksumPolicy = {
24
+ required: boolean;
25
+ extension?: string;
26
+ };
27
+
28
+ export type UpdaterCompanionBinary = {
29
+ binaryName: string;
30
+ assetPrefix?: string;
31
+ required?: boolean;
32
+ };
33
+
34
+ export type UpdaterConfig = {
35
+ repository: GitHubRepository | string;
36
+ binaryName: string;
37
+ currentVersion: string;
38
+ productName?: string;
39
+ assetPrefix?: string;
40
+ checksum?: UpdaterChecksumPolicy;
41
+ companionBinaries?: UpdaterCompanionBinary[];
42
+ tempDirectoryPrefix?: string;
43
+ userAgent?: string;
44
+ };
45
+
46
+ type WritableBinaryContent = Uint8Array | string;
47
+
48
+ export type UpdaterDependencies = {
49
+ fetchFn: typeof fetch;
50
+ out: (message: string) => void;
51
+ err: (message: string) => void;
52
+ getPlatform: () => {
53
+ platform: string;
54
+ arch: string;
55
+ };
56
+ getExecutablePath: () => string;
57
+ resolveRealPath: (path: string) => Promise<string>;
58
+ fileExists: (path: string) => Promise<boolean>;
59
+ createTempDirectory: (targetDirectory: string, prefix: string) => Promise<string>;
60
+ writeBinary: (path: string, content: WritableBinaryContent) => Promise<void>;
61
+ chmodFile: (path: string, mode: number) => Promise<void>;
62
+ renameFile: (from: string, to: string) => Promise<void>;
63
+ removeFile: (path: string) => Promise<void>;
64
+ statFile: (path: string) => Promise<{ mode: number }>;
65
+ };
66
+
67
+ export type GitHubReleaseAsset = {
68
+ name: string;
69
+ browser_download_url: string;
70
+ };
71
+
72
+ export type GitHubRelease = {
73
+ tag_name: string;
74
+ assets: GitHubReleaseAsset[];
75
+ };
76
+
77
+ export type ResolvedReleaseAsset = {
78
+ binaryName: string;
79
+ version: string;
80
+ assetName: string;
81
+ downloadUrl: string;
82
+ checksumAssetName: string;
83
+ checksumDownloadUrl?: string;
84
+ };
85
+
86
+ type InstalledBinaryTarget = {
87
+ binaryName: string;
88
+ assetPrefix: string;
89
+ targetPath: string;
90
+ required: boolean;
91
+ };
92
+
93
+ type StagedBinaryReplacement = {
94
+ target: InstalledBinaryTarget;
95
+ asset: ResolvedReleaseAsset;
96
+ tempDirectory: string;
97
+ tempPath: string;
98
+ backupPath: string;
99
+ };
100
+
101
+ function createDefaultUpdateDependencies(): UpdaterDependencies {
102
+ return {
103
+ fetchFn: fetch,
104
+ out: console.log,
105
+ err: console.error,
106
+ getPlatform: () => ({
107
+ platform: process.platform,
108
+ arch: process.arch,
109
+ }),
110
+ getExecutablePath: () => process.execPath,
111
+ resolveRealPath: async path => await realpath(path),
112
+ fileExists: async path => await Bun.file(path).exists(),
113
+ createTempDirectory: async (targetDirectory, prefix) => await mkdtemp(join(targetDirectory, prefix)),
114
+ writeBinary: async (path, content) => {
115
+ await Bun.write(path, content);
116
+ },
117
+ chmodFile: async (path, mode) => {
118
+ await chmod(path, mode);
119
+ },
120
+ renameFile: async (from, to) => {
121
+ await rename(from, to);
122
+ },
123
+ removeFile: async path => {
124
+ await rm(path, { force: true, recursive: true });
125
+ },
126
+ statFile: async path => {
127
+ const result = await stat(path);
128
+ return { mode: result.mode };
129
+ },
130
+ };
131
+ }
132
+
133
+ function assertReleaseAsset(value: unknown, index: number): GitHubReleaseAsset {
134
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
135
+ throw new Error(`release.assets[${index}] must be an object.`);
136
+ }
137
+ const raw = value as Record<string, unknown>;
138
+ if (typeof raw["name"] !== "string" || raw["name"] === "") {
139
+ throw new Error(`release.assets[${index}].name must be a non-empty string.`);
140
+ }
141
+ if (typeof raw["browser_download_url"] !== "string" || raw["browser_download_url"] === "") {
142
+ throw new Error(`release.assets[${index}].browser_download_url must be a non-empty string.`);
143
+ }
144
+ return {
145
+ name: raw["name"],
146
+ browser_download_url: raw["browser_download_url"],
147
+ };
148
+ }
149
+
150
+ export function parseGitHubRelease(value: unknown): GitHubRelease {
151
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
152
+ throw new Error("release must be an object.");
153
+ }
154
+ const raw = value as Record<string, unknown>;
155
+ if (typeof raw["tag_name"] !== "string" || raw["tag_name"] === "") {
156
+ throw new Error("release.tag_name must be a non-empty string.");
157
+ }
158
+ if (!Array.isArray(raw["assets"])) {
159
+ throw new Error("release.assets must be an array.");
160
+ }
161
+ return {
162
+ tag_name: raw["tag_name"],
163
+ assets: raw["assets"].map(assertReleaseAsset),
164
+ };
165
+ }
166
+
167
+ export async function fetchGitHubRelease(
168
+ config: UpdaterConfig,
169
+ command: Pick<UpdateCommandOptions, "version">,
170
+ dependencies: Pick<UpdaterDependencies, "fetchFn" | "out">,
171
+ ): Promise<GitHubRelease> {
172
+ const repository = assertGitHubRepository(config.repository);
173
+ const tag = command.version ? normalizeReleaseTag(command.version) : undefined;
174
+ const releaseUrl = tag
175
+ ? `${githubApiRepositoryUrl(repository)}/releases/tags/${tag}`
176
+ : `${githubApiRepositoryUrl(repository)}/releases/latest`;
177
+ dependencies.out(tag ? `Fetching release metadata for ${tag}...` : "Fetching release metadata...");
178
+ const response = await dependencies.fetchFn(releaseUrl, {
179
+ headers: {
180
+ accept: "application/vnd.github+json",
181
+ "user-agent": config.userAgent ?? `${config.binaryName}-updater`,
182
+ "x-github-api-version": DEFAULT_GITHUB_API_VERSION,
183
+ },
184
+ });
185
+
186
+ if (response.status === 404 && tag) {
187
+ throw new Error(`Release not found: ${tag}`);
188
+ }
189
+ if (!response.ok) {
190
+ throw new Error(`Failed to load release metadata: GitHub returned ${String(response.status)}.`);
191
+ }
192
+
193
+ let rawBody: unknown;
194
+ try {
195
+ rawBody = await response.json();
196
+ } catch (error) {
197
+ throw new Error(`Failed to parse release metadata: ${String(error)}`);
198
+ }
199
+ return parseGitHubRelease(rawBody);
200
+ }
201
+
202
+ export function resolveReleaseAsset(
203
+ release: GitHubRelease,
204
+ target: ReleasePlatform,
205
+ config: Pick<UpdaterConfig, "binaryName" | "assetPrefix" | "checksum">,
206
+ ): ResolvedReleaseAsset {
207
+ const assetPrefix = config.assetPrefix ?? config.binaryName;
208
+ const checksumExtension = config.checksum?.extension ?? DEFAULT_CHECKSUM_EXTENSION;
209
+ const assetName = buildReleaseAssetName(assetPrefix, release.tag_name, target);
210
+ const checksumAssetName = `${assetName}${checksumExtension}`;
211
+ const asset = release.assets.find(entry => entry.name === assetName);
212
+ if (!asset) {
213
+ throw new Error(`Release ${release.tag_name} does not include asset ${assetName}.`);
214
+ }
215
+ const checksumAsset = release.assets.find(entry => entry.name === checksumAssetName);
216
+
217
+ return {
218
+ binaryName: config.binaryName,
219
+ version: normalizeReleaseVersion(release.tag_name),
220
+ assetName,
221
+ downloadUrl: asset.browser_download_url,
222
+ checksumAssetName,
223
+ checksumDownloadUrl: checksumAsset?.browser_download_url,
224
+ };
225
+ }
226
+
227
+ function formatCheckMessage(binaryName: string, currentVersion: string, targetVersion: string): string {
228
+ const comparison = compareReleaseVersions(currentVersion, targetVersion);
229
+ if (comparison === 0) {
230
+ return `${binaryName} ${currentVersion} is up to date.`;
231
+ }
232
+ if (comparison > 0) {
233
+ return `${binaryName} ${currentVersion} is newer than the latest published release ${targetVersion}.`;
234
+ }
235
+ return `Update available: ${currentVersion} -> ${targetVersion}`;
236
+ }
237
+
238
+ function toPermissionMessage(productName: string, path: string, error: unknown): Error {
239
+ const code = typeof error === "object" && error && "code" in error ? String(error.code) : undefined;
240
+ if (code === "EACCES" || code === "EPERM") {
241
+ return new Error(
242
+ `Cannot update ${path}: permission denied. Re-run with permission to modify the installed binary or use the installer script.`,
243
+ );
244
+ }
245
+ if (code === "EBUSY" || code === "ETXTBSY") {
246
+ return new Error(`Cannot update ${path}: the binary is currently in use. Stop any running ${productName} process and try again.`);
247
+ }
248
+ return new Error(`Failed to update ${path}: ${String(error)}`);
249
+ }
250
+
251
+ export function parseExpectedSha256(checksumText: string, assetName: string): string {
252
+ const plainHashes: string[] = [];
253
+
254
+ for (const rawLine of checksumText.split(/\r?\n/)) {
255
+ const line = rawLine.trim();
256
+ if (!line) {
257
+ continue;
258
+ }
259
+ const [hash, ...fileParts] = line.split(/\s+/);
260
+ if (!hash || !/^[0-9a-fA-F]{64}$/.test(hash)) {
261
+ continue;
262
+ }
263
+ const normalizedHash = hash.toLowerCase();
264
+ const fileName = fileParts.join(" ").replace(/^\*/, "");
265
+ if (fileName) {
266
+ if (basename(fileName) === assetName) {
267
+ return normalizedHash;
268
+ }
269
+ continue;
270
+ }
271
+ plainHashes.push(normalizedHash);
272
+ }
273
+
274
+ if (plainHashes.length === 1) {
275
+ return plainHashes[0]!;
276
+ }
277
+ throw new Error(`Checksum for ${assetName} did not contain a valid SHA-256 entry.`);
278
+ }
279
+
280
+ export async function verifyReleaseAssetChecksum(
281
+ asset: ResolvedReleaseAsset,
282
+ payload: Uint8Array,
283
+ checksumPolicy: Required<UpdaterChecksumPolicy>,
284
+ dependencies: Pick<UpdaterDependencies, "fetchFn" | "out">,
285
+ ): Promise<void> {
286
+ if (!asset.checksumDownloadUrl) {
287
+ if (checksumPolicy.required) {
288
+ throw new Error(`Release asset ${asset.checksumAssetName} is required to verify ${asset.assetName}.`);
289
+ }
290
+ dependencies.out(`Skipping checksum verification for ${asset.assetName}; ${asset.checksumAssetName} was not published.`);
291
+ return;
292
+ }
293
+
294
+ dependencies.out(`Downloading ${asset.checksumAssetName}...`);
295
+ const response = await dependencies.fetchFn(asset.checksumDownloadUrl);
296
+ if (!response.ok) {
297
+ throw new Error(`Failed to download ${asset.checksumAssetName}: GitHub returned ${String(response.status)}.`);
298
+ }
299
+
300
+ const expected = parseExpectedSha256(await response.text(), asset.assetName);
301
+ const actual = createHash("sha256").update(payload).digest("hex");
302
+ if (actual !== expected) {
303
+ throw new Error(`Checksum verification failed for ${asset.assetName}: expected ${expected}, got ${actual}.`);
304
+ }
305
+ dependencies.out(`Verified checksum for ${asset.assetName}.`);
306
+ }
307
+
308
+ async function resolveInstalledBinaryPath(binaryName: string, productName: string, dependencies: UpdaterDependencies): Promise<string> {
309
+ const executablePath = dependencies.getExecutablePath();
310
+ const executableName = basename(executablePath);
311
+ if (executableName === "bun" || executableName.startsWith("bun-")) {
312
+ throw new Error(`${binaryName} update only works from an installed ${productName} binary. Use the installer script when running from source.`);
313
+ }
314
+ const resolvedPath = await dependencies.resolveRealPath(executablePath);
315
+ if (!(await dependencies.fileExists(resolvedPath))) {
316
+ throw new Error(`Installed binary does not exist: ${resolvedPath}`);
317
+ }
318
+ return resolvedPath;
319
+ }
320
+
321
+ async function stageInstalledBinaryReplacement(
322
+ target: InstalledBinaryTarget,
323
+ asset: ResolvedReleaseAsset,
324
+ config: UpdaterConfig,
325
+ checksumPolicy: Required<UpdaterChecksumPolicy>,
326
+ dependencies: UpdaterDependencies,
327
+ ): Promise<StagedBinaryReplacement> {
328
+ const targetPath = target.targetPath;
329
+ let tempDirectory: string | undefined;
330
+ let tempPath: string | undefined;
331
+ let tempCreated = false;
332
+ let staged = false;
333
+
334
+ try {
335
+ tempDirectory = await dependencies.createTempDirectory(dirname(targetPath), config.tempDirectoryPrefix ?? `.${config.binaryName}-update-`);
336
+ tempPath = join(tempDirectory, asset.assetName);
337
+ dependencies.out(`Downloading ${asset.assetName}...`);
338
+ const response = await dependencies.fetchFn(asset.downloadUrl);
339
+ if (!response.ok) {
340
+ throw new Error(`Failed to download ${asset.assetName}: GitHub returned ${String(response.status)}.`);
341
+ }
342
+
343
+ const payload = new Uint8Array(await response.arrayBuffer());
344
+ await verifyReleaseAssetChecksum(asset, payload, checksumPolicy, dependencies);
345
+ await dependencies.writeBinary(tempPath, payload);
346
+ tempCreated = true;
347
+
348
+ const installedBinaryStat = await dependencies.statFile(targetPath);
349
+ const executableMode = installedBinaryStat.mode & 0o777;
350
+ await dependencies.chmodFile(tempPath, executableMode || 0o755);
351
+ staged = true;
352
+ return {
353
+ target,
354
+ asset,
355
+ tempDirectory,
356
+ tempPath,
357
+ backupPath: join(tempDirectory, `${basename(targetPath)}.backup`),
358
+ };
359
+ } catch (error) {
360
+ throw toPermissionMessage(config.productName ?? config.binaryName, targetPath, error);
361
+ } finally {
362
+ if (!staged) {
363
+ if (tempPath) {
364
+ await dependencies.removeFile(tempPath);
365
+ }
366
+ if (tempDirectory) {
367
+ await dependencies.removeFile(tempDirectory);
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ async function cleanupStagedBinaryReplacements(
374
+ stagedReplacements: StagedBinaryReplacement[],
375
+ dependencies: Pick<UpdaterDependencies, "removeFile">,
376
+ ): Promise<void> {
377
+ for (const staged of stagedReplacements) {
378
+ await dependencies.removeFile(staged.tempDirectory);
379
+ }
380
+ }
381
+
382
+ async function replaceStagedBinaryReplacements(
383
+ stagedReplacements: StagedBinaryReplacement[],
384
+ config: UpdaterConfig,
385
+ dependencies: UpdaterDependencies,
386
+ ): Promise<void> {
387
+ const movedBackups: StagedBinaryReplacement[] = [];
388
+ const installedReplacements: StagedBinaryReplacement[] = [];
389
+
390
+ try {
391
+ for (const staged of stagedReplacements) {
392
+ dependencies.out(`Replacing ${staged.target.targetPath}...`);
393
+ await dependencies.renameFile(staged.target.targetPath, staged.backupPath);
394
+ movedBackups.push(staged);
395
+ await dependencies.renameFile(staged.tempPath, staged.target.targetPath);
396
+ installedReplacements.push(staged);
397
+ }
398
+ } catch (error) {
399
+ for (const staged of installedReplacements.reverse()) {
400
+ await dependencies.removeFile(staged.target.targetPath);
401
+ }
402
+ for (const staged of movedBackups.reverse()) {
403
+ await dependencies.renameFile(staged.backupPath, staged.target.targetPath);
404
+ }
405
+ throw toPermissionMessage(config.productName ?? config.binaryName, config.binaryName, error);
406
+ }
407
+ }
408
+
409
+ async function resolveInstalledBinaryTargets(config: UpdaterConfig, dependencies: UpdaterDependencies): Promise<InstalledBinaryTarget[]> {
410
+ const primaryPath = await resolveInstalledBinaryPath(config.binaryName, config.productName ?? config.binaryName, dependencies);
411
+ const targets: InstalledBinaryTarget[] = [];
412
+ for (const companion of config.companionBinaries ?? []) {
413
+ const companionPath = join(dirname(primaryPath), companion.binaryName);
414
+ if (await dependencies.fileExists(companionPath)) {
415
+ targets.push({
416
+ binaryName: companion.binaryName,
417
+ assetPrefix: companion.assetPrefix ?? companion.binaryName,
418
+ targetPath: companionPath,
419
+ required: companion.required ?? false,
420
+ });
421
+ } else if (companion.required) {
422
+ throw new Error(`Required companion binary does not exist: ${companionPath}`);
423
+ }
424
+ }
425
+ targets.push({
426
+ binaryName: config.binaryName,
427
+ assetPrefix: config.assetPrefix ?? config.binaryName,
428
+ targetPath: primaryPath,
429
+ required: true,
430
+ });
431
+ return targets;
432
+ }
433
+
434
+ export async function runUpdateCommand(
435
+ command: UpdateCommandOptions,
436
+ config: UpdaterConfig,
437
+ dependencyOverrides: Partial<UpdaterDependencies> = {},
438
+ ): Promise<number> {
439
+ const dependencies = {
440
+ ...createDefaultUpdateDependencies(),
441
+ ...dependencyOverrides,
442
+ };
443
+ const normalizedConfig = {
444
+ ...config,
445
+ repository: assertGitHubRepository(config.repository),
446
+ };
447
+ const checksumPolicy = {
448
+ required: normalizedConfig.checksum?.required ?? true,
449
+ extension: normalizedConfig.checksum?.extension ?? DEFAULT_CHECKSUM_EXTENSION,
450
+ };
451
+ const currentVersion = normalizeReleaseVersion(normalizedConfig.currentVersion);
452
+ const release = await fetchGitHubRelease(normalizedConfig, command, dependencies);
453
+ const runtimePlatform = dependencies.getPlatform();
454
+ const releasePlatform = resolveReleasePlatform(runtimePlatform.platform, runtimePlatform.arch);
455
+ const primaryAsset = resolveReleaseAsset(release, releasePlatform, {
456
+ binaryName: normalizedConfig.binaryName,
457
+ assetPrefix: normalizedConfig.assetPrefix,
458
+ checksum: checksumPolicy,
459
+ });
460
+
461
+ if (command.checkOnly) {
462
+ dependencies.out(formatCheckMessage(normalizedConfig.binaryName, currentVersion, primaryAsset.version));
463
+ return 0;
464
+ }
465
+
466
+ if (!command.version && compareReleaseVersions(currentVersion, primaryAsset.version) >= 0) {
467
+ dependencies.out(formatCheckMessage(normalizedConfig.binaryName, currentVersion, primaryAsset.version));
468
+ return 0;
469
+ }
470
+
471
+ if (command.version && compareReleaseVersions(currentVersion, primaryAsset.version) === 0) {
472
+ dependencies.out(`${normalizedConfig.binaryName} ${currentVersion} is already installed.`);
473
+ return 0;
474
+ }
475
+
476
+ const stagedReplacements: StagedBinaryReplacement[] = [];
477
+ try {
478
+ const installedTargets = await resolveInstalledBinaryTargets(normalizedConfig, dependencies);
479
+ for (const target of installedTargets) {
480
+ const asset = resolveReleaseAsset(release, releasePlatform, {
481
+ binaryName: target.binaryName,
482
+ assetPrefix: target.assetPrefix,
483
+ checksum: checksumPolicy,
484
+ });
485
+ stagedReplacements.push(await stageInstalledBinaryReplacement(target, asset, normalizedConfig, checksumPolicy, dependencies));
486
+ }
487
+
488
+ await replaceStagedBinaryReplacements(stagedReplacements, normalizedConfig, dependencies);
489
+
490
+ for (const { target, asset } of stagedReplacements) {
491
+ if (command.version) {
492
+ dependencies.out(`Installed ${target.binaryName} ${asset.version} at ${target.targetPath}.`);
493
+ } else {
494
+ dependencies.out(`Updated ${target.binaryName} ${currentVersion} -> ${asset.version} at ${target.targetPath}.`);
495
+ }
496
+ }
497
+ } finally {
498
+ await cleanupStagedBinaryReplacements(stagedReplacements, dependencies);
499
+ }
500
+
501
+ return 0;
502
+ }