@pablozaiden/installer 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -184,12 +184,18 @@ Use `runUpdateCommand` from an installed binary's `update` command.
184
184
  import { runUpdateCommand } from "@pablozaiden/installer";
185
185
  import { MYPROJECT_VERSION } from "./version";
186
186
 
187
- export async function runCliCommand(command: { kind: string; checkOnly?: boolean; version?: string }) {
187
+ export async function runCliCommand(command: {
188
+ kind: string;
189
+ checkOnly?: boolean;
190
+ version?: string;
191
+ preRelease?: boolean;
192
+ }) {
188
193
  if (command.kind === "update") {
189
194
  return await runUpdateCommand(
190
195
  {
191
196
  checkOnly: command.checkOnly ?? false,
192
197
  version: command.version,
198
+ preRelease: command.preRelease,
193
199
  },
194
200
  {
195
201
  repository: "pablozaiden/myproject",
@@ -203,6 +209,12 @@ export async function runCliCommand(command: { kind: string; checkOnly?: boolean
203
209
  }
204
210
  ```
205
211
 
212
+ Set `preRelease: true` to consider published prereleases. The updater selects
213
+ the prerelease with the highest semantic version only when it is newer than
214
+ the latest stable release; otherwise, it uses the stable release. The same
215
+ selection applies to `checkOnly`. An explicit `version` continues to install
216
+ that exact release.
217
+
206
218
  For a CLI with a companion binary installed beside it:
207
219
 
208
220
  ```ts
@@ -234,8 +246,9 @@ await runUpdateCommand(
234
246
  The updater supports:
235
247
 
236
248
  - latest release checks,
249
+ - opt-in prerelease selection when a prerelease is newer than the latest stable release,
237
250
  - explicit version installs,
238
- - semver comparison including prereleases,
251
+ - SemVer comparison including prereleases, with build metadata ignored for precedence,
239
252
  - GitHub release metadata validation,
240
253
  - Linux/macOS/Windows x64/arm64 target resolution,
241
254
  - checksum verification before replacement,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/installer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reusable GitHub release binary installer and updater utilities",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contract.ts CHANGED
@@ -46,7 +46,7 @@ type ParsedVersion = {
46
46
  };
47
47
 
48
48
  function parseVersion(value: string): ParsedVersion | null {
49
- const parsed = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(normalizeReleaseVersion(value));
49
+ const parsed = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalizeReleaseVersion(value));
50
50
  if (!parsed) {
51
51
  return null;
52
52
  }
package/src/update.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  export type UpdateCommandOptions = {
20
20
  checkOnly: boolean;
21
21
  version?: string;
22
+ preRelease?: boolean;
22
23
  };
23
24
 
24
25
  export type UpdaterChecksumPolicy = {
@@ -75,6 +76,8 @@ export type GitHubReleaseAsset = {
75
76
  export type GitHubRelease = {
76
77
  tag_name: string;
77
78
  assets: GitHubReleaseAsset[];
79
+ draft?: boolean;
80
+ prerelease?: boolean;
78
81
  };
79
82
 
80
83
  export type ResolvedReleaseAsset = {
@@ -271,15 +274,102 @@ export function parseGitHubRelease(value: unknown): GitHubRelease {
271
274
  if (!Array.isArray(raw["assets"])) {
272
275
  throw new Error("release.assets must be an array.");
273
276
  }
277
+ const draft = parseOptionalBoolean(raw["draft"], "release.draft");
278
+ const prerelease = parseOptionalBoolean(raw["prerelease"], "release.prerelease");
274
279
  return {
275
280
  tag_name: raw["tag_name"],
276
281
  assets: raw["assets"].map(assertReleaseAsset),
282
+ ...(draft === undefined ? {} : { draft }),
283
+ ...(prerelease === undefined ? {} : { prerelease }),
277
284
  };
278
285
  }
279
286
 
287
+ function parseOptionalBoolean(value: unknown, label: string): boolean | undefined {
288
+ if (value === undefined) {
289
+ return undefined;
290
+ }
291
+ if (typeof value !== "boolean") {
292
+ throw new Error(`${label} must be a boolean.`);
293
+ }
294
+ return value;
295
+ }
296
+
297
+ function parseGitHubReleaseList(value: unknown): GitHubRelease[] {
298
+ if (!Array.isArray(value)) {
299
+ throw new Error("releases must be an array.");
300
+ }
301
+ return value.map((entry, index) => {
302
+ const release = parseGitHubRelease(entry);
303
+ if (release.draft === undefined) {
304
+ throw new Error(`releases[${index}].draft must be a boolean.`);
305
+ }
306
+ if (release.prerelease === undefined) {
307
+ throw new Error(`releases[${index}].prerelease must be a boolean.`);
308
+ }
309
+ return release;
310
+ });
311
+ }
312
+
313
+ async function fetchGitHubApiResponse(
314
+ config: UpdaterConfig,
315
+ url: string,
316
+ dependencies: Pick<UpdaterDependencies, "fetchFn">,
317
+ ): Promise<Response> {
318
+ return await dependencies.fetchFn(url, {
319
+ headers: {
320
+ accept: "application/vnd.github+json",
321
+ "user-agent": config.userAgent ?? `${config.binaryName}-updater`,
322
+ "x-github-api-version": DEFAULT_GITHUB_API_VERSION,
323
+ },
324
+ });
325
+ }
326
+
327
+ async function parseGitHubApiResponse(response: Response, metadataName: string): Promise<unknown> {
328
+ try {
329
+ return await response.json();
330
+ } catch (error) {
331
+ throw new Error(`Failed to parse ${metadataName}: ${String(error)}`);
332
+ }
333
+ }
334
+
335
+ function hasNextReleasePage(response: Response): boolean {
336
+ return response.headers.get("link")
337
+ ?.split(",")
338
+ .some(link => /;\s*rel="?next"?\s*(?:;|$)/i.test(link)) ?? false;
339
+ }
340
+
341
+ async function fetchGitHubReleases(
342
+ repository: GitHubRepository,
343
+ config: UpdaterConfig,
344
+ dependencies: Pick<UpdaterDependencies, "fetchFn">,
345
+ ): Promise<GitHubRelease[]> {
346
+ const releasesUrl = `${githubApiRepositoryUrl(repository)}/releases`;
347
+ const releases: GitHubRelease[] = [];
348
+ let page = 1;
349
+
350
+ while (true) {
351
+ const response = await fetchGitHubApiResponse(
352
+ config,
353
+ `${releasesUrl}?per_page=100&page=${String(page)}`,
354
+ dependencies,
355
+ );
356
+ if (!response.ok) {
357
+ throw new Error(`Failed to load prerelease metadata: GitHub returned ${String(response.status)}.`);
358
+ }
359
+ const pageReleases = parseGitHubReleaseList(
360
+ await parseGitHubApiResponse(response, "prerelease metadata"),
361
+ );
362
+ releases.push(...pageReleases);
363
+ if (!hasNextReleasePage(response)) {
364
+ return releases;
365
+ }
366
+ page += 1;
367
+ }
368
+ }
369
+
280
370
  export async function fetchGitHubRelease(
281
371
  config: UpdaterConfig,
282
- command: Pick<UpdateCommandOptions, "version">,
372
+ command: Pick<UpdateCommandOptions, "version" | "preRelease">,
283
373
  dependencies: Pick<UpdaterDependencies, "fetchFn" | "out">,
284
374
  ): Promise<GitHubRelease> {
285
375
  const repository = assertGitHubRepository(config.repository);
@@ -288,13 +378,7 @@ export async function fetchGitHubRelease(
288
378
  ? `${githubApiRepositoryUrl(repository)}/releases/tags/${tag}`
289
379
  : `${githubApiRepositoryUrl(repository)}/releases/latest`;
290
380
  dependencies.out(tag ? `Fetching release metadata for ${tag}...` : "Fetching release metadata...");
291
- const response = await dependencies.fetchFn(releaseUrl, {
292
- headers: {
293
- accept: "application/vnd.github+json",
294
- "user-agent": config.userAgent ?? `${config.binaryName}-updater`,
295
- "x-github-api-version": DEFAULT_GITHUB_API_VERSION,
296
- },
297
- });
381
+ const response = await fetchGitHubApiResponse(config, releaseUrl, dependencies);
298
382
 
299
383
  if (response.status === 404 && tag) {
300
384
  throw new Error(`Release not found: ${tag}`);
@@ -303,13 +387,27 @@ export async function fetchGitHubRelease(
303
387
  throw new Error(`Failed to load release metadata: GitHub returned ${String(response.status)}.`);
304
388
  }
305
389
 
306
- let rawBody: unknown;
307
- try {
308
- rawBody = await response.json();
309
- } catch (error) {
310
- throw new Error(`Failed to parse release metadata: ${String(error)}`);
390
+ const release = parseGitHubRelease(await parseGitHubApiResponse(response, "release metadata"));
391
+ if (tag || !command.preRelease) {
392
+ return release;
393
+ }
394
+
395
+ dependencies.out("Checking for a newer prerelease...");
396
+ const prereleases = (await fetchGitHubReleases(repository, config, dependencies))
397
+ .filter(candidate => candidate.prerelease === true && candidate.draft !== true);
398
+ const newestPrerelease = prereleases.reduce<GitHubRelease | undefined>(
399
+ (newest, candidate) => !newest || compareReleaseVersions(candidate.tag_name, newest.tag_name) > 0
400
+ ? candidate
401
+ : newest,
402
+ undefined,
403
+ );
404
+ if (
405
+ newestPrerelease
406
+ && compareReleaseVersions(newestPrerelease.tag_name, release.tag_name) > 0
407
+ ) {
408
+ return newestPrerelease;
311
409
  }
312
- return parseGitHubRelease(rawBody);
410
+ return release;
313
411
  }
314
412
 
315
413
  export function resolveReleaseAsset(