@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Linux distributable packages for a mirin app — **AppImage**, **.deb**, **.rpm** —
3
+ * built from the assembled Linux app folder (buildLinuxBundle's output). This is the
4
+ * Linux analog of dmg.ts (macOS) and installer-inno.ts / installer-win.ts (Windows):
5
+ * `mirin build` (with packaging requested) and `mirin release` call it after the app
6
+ * folder is assembled.
7
+ *
8
+ * The app folder is a flat, relocatable payload — the host binary, libcef.so + the
9
+ * CEF runtime, and `resources/`, all resolved at runtime from an `$ORIGIN` rpath and
10
+ * `dirname(process.execPath)` (host.ts). Every package therefore ships the whole
11
+ * folder verbatim under one prefix and points a launcher at the *real* host binary
12
+ * inside it (never a bare PATH name — the host derives everything from its own dir):
13
+ *
14
+ * - deb / rpm: payload → `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`s
15
+ * `/opt/<id>/<App>`, plus `/usr/share/applications/<id>.desktop` and a
16
+ * `/usr/share/icons/hicolor/256x256/apps/<id>.png`.
17
+ * - AppImage: payload → `AppDir/usr/lib/<id>/`, top-level `AppRun` `exec`s it,
18
+ * `<id>.desktop` + `<id>.png` (+ `.DirIcon`) at the AppDir root, packed by
19
+ * appimagetool.
20
+ *
21
+ * Tooling (resolved via $MIRIN_FPM / $MIRIN_APPIMAGETOOL, then PATH, then a
22
+ * conventional fallback — see resolveTool):
23
+ * - **fpm** builds both deb and rpm in pure Ruby (no dpkg/rpmbuild on the host).
24
+ * - **appimagetool** packs the AppImage (invoked with `--appimage-extract-and-run`
25
+ * so it works on hosts without FUSE).
26
+ */
27
+
28
+ import {
29
+ chmodSync,
30
+ cpSync,
31
+ existsSync,
32
+ mkdirSync,
33
+ rmSync,
34
+ statSync,
35
+ symlinkSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { $ } from "bun";
40
+ import { desktopEntry, resolveLinuxDesktopIconPng } from "../../bundle/linux/index.ts";
41
+ import { DEFAULT_DEB_DEPENDS, DEFAULT_RPM_DEPENDS, LINUX_FORMATS } from "./lib/defaults.ts";
42
+ import { resolveAppimagetool, resolveFpm } from "./lib/tools.ts";
43
+ import { safeLinuxPackageInput, safePackageName, validateLinuxFormats } from "./lib/validation.ts";
44
+ import type {
45
+ LinuxConfig,
46
+ LinuxPackageFormat,
47
+ LinuxPackageInput,
48
+ LinuxPackageResult,
49
+ } from "./types.ts";
50
+
51
+ const size = (p: string) => statSync(p).size;
52
+
53
+ /** Build every requested Linux package; returns each artifact's path + size. */
54
+ export async function buildLinuxPackages(input: LinuxPackageInput): Promise<LinuxPackageResult[]> {
55
+ const safeInput = safeLinuxPackageInput(input);
56
+ mkdirSync(safeInput.outDir, { recursive: true });
57
+ const binName = safePackageName(
58
+ safeInput.options.binName ?? safeInput.bundleId.split(".").pop() ?? safeInput.appName,
59
+ );
60
+ // deb + rpm share one staged filesystem tree (payload under /opt, launcher,
61
+ // .desktop, icon). Build it once and reuse. Package compressors only read the
62
+ // staged tree, so all requested formats can run concurrently.
63
+ const needFsTree = safeInput.formats.includes("deb") || safeInput.formats.includes("rpm");
64
+ let fsRoot: string | undefined;
65
+ try {
66
+ if (needFsTree) fsRoot = stageFsTree(safeInput, binName);
67
+ return await Promise.all(
68
+ safeInput.formats.map((format) =>
69
+ format === "appimage"
70
+ ? buildAppImage(safeInput, binName)
71
+ : buildFpm(safeInput, binName, fsRoot!, format),
72
+ ),
73
+ );
74
+ } finally {
75
+ if (fsRoot) rmSync(fsRoot, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Stage the on-disk filesystem layout deb/rpm install: the whole payload under
81
+ * `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`ing the real host binary, the
82
+ * `.desktop` entry, and a hicolor icon. Returns the staging root (fpm's `-C` chdir).
83
+ */
84
+ function stageFsTree(input: LinuxPackageInput, binName: string): string {
85
+ const root = join(input.outDir, `.pkgroot-${process.pid}`);
86
+ rmSync(root, { recursive: true, force: true });
87
+
88
+ // Payload → /opt/<id>/…
89
+ const prefix = join(root, "opt", input.bundleId);
90
+ mkdirSync(prefix, { recursive: true });
91
+ cpSync(input.appDir, prefix, { recursive: true });
92
+
93
+ // /usr/bin/<bin>: a wrapper that exec's the real binary so process.execPath (and
94
+ // thus the host's core/resources/helper resolution) points into /opt/<id>.
95
+ const binDir = join(root, "usr", "bin");
96
+ mkdirSync(binDir, { recursive: true });
97
+ const launcher = join(binDir, binName);
98
+ writeFileSync(launcher, `#!/bin/sh\nexec "/opt/${input.bundleId}/${input.appName}" "$@"\n`);
99
+ chmodSync(launcher, 0o755);
100
+
101
+ // /usr/share/applications/<id>.desktop — Icon= is the theme name (<id>), resolved
102
+ // from the hicolor icon below; Exec= is the /usr/bin launcher on PATH.
103
+ const appsDir = join(root, "usr", "share", "applications");
104
+ mkdirSync(appsDir, { recursive: true });
105
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), buildDesktop(input, binName));
106
+
107
+ stageHicolorIcon(root, input);
108
+ return root;
109
+ }
110
+
111
+ /** Install the app icon at `usr/share/icons/hicolor/256x256/apps/<id>.png` under `root`. */
112
+ function stageHicolorIcon(root: string, input: LinuxPackageInput): void {
113
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
114
+ if (!png) return;
115
+ const dir = join(root, "usr", "share", "icons", "hicolor", "256x256", "apps");
116
+ mkdirSync(dir, { recursive: true });
117
+ cpSync(png, join(dir, `${input.bundleId}.png`));
118
+ }
119
+
120
+ /** The `.desktop` body shared by deb/rpm/AppImage (Exec = launcher name, Icon = <id>). */
121
+ function buildDesktop(input: LinuxPackageInput, binName: string): string {
122
+ return desktopEntry({
123
+ name: input.appName,
124
+ exec: binName,
125
+ iconPng: input.bundleId,
126
+ wmClass: input.bundleId,
127
+ categories: input.options.category ?? "Utility",
128
+ comment: input.options.description,
129
+ });
130
+ }
131
+
132
+ /** Build a .deb or .rpm from the staged tree via fpm. */
133
+ async function buildFpm(
134
+ input: LinuxPackageInput,
135
+ binName: string,
136
+ fsRoot: string,
137
+ kind: "deb" | "rpm",
138
+ ): Promise<LinuxPackageResult> {
139
+ const fpm = resolveFpm();
140
+ const arch = linuxPackageArch(kind);
141
+ const fileName =
142
+ kind === "deb"
143
+ ? `${binName}_${input.version}_${arch}.deb`
144
+ : `${binName}-${input.version}-1.${arch}.rpm`;
145
+ const out = join(input.outDir, fileName);
146
+ rmSync(out, { force: true });
147
+
148
+ const depends =
149
+ kind === "deb"
150
+ ? (input.options.debDepends ?? DEFAULT_DEB_DEPENDS)
151
+ : (input.options.rpmDepends ?? DEFAULT_RPM_DEPENDS);
152
+
153
+ const args: string[] = [
154
+ "-s",
155
+ "dir",
156
+ "-t",
157
+ kind,
158
+ "-C",
159
+ fsRoot,
160
+ "--package",
161
+ out,
162
+ "--name",
163
+ binName,
164
+ "--version",
165
+ input.version,
166
+ "--iteration",
167
+ "1",
168
+ "--architecture",
169
+ arch,
170
+ "--maintainer",
171
+ input.options.maintainer ?? input.publisher,
172
+ "--vendor",
173
+ input.publisher,
174
+ "--description",
175
+ input.options.description ?? input.appName,
176
+ ];
177
+ if (input.options.homepage) args.push("--url", input.options.homepage);
178
+ if (input.options.license) args.push("--license", input.options.license);
179
+ for (const d of depends) args.push("--depends", d);
180
+ // CEF dominates the payload. Level 3 avoids spending minutes chasing small
181
+ // gains while retaining gzip compatibility across Debian and RPM families.
182
+ args.push(
183
+ kind === "deb" ? "--deb-compression" : "--rpm-compression",
184
+ kind === "deb" ? "gz" : "gzip",
185
+ kind === "deb" ? "--deb-compression-level" : "--rpm-compression-level",
186
+ "3",
187
+ );
188
+ // Package the whole staged tree ('.' relative to the -C chdir).
189
+ args.push(".");
190
+
191
+ console.log(`[mirin] building ${kind} → ${fileName}`);
192
+ const res = await $`${fpm} ${args}`.nothrow().quiet();
193
+ if (res.exitCode !== 0 || !existsSync(out)) {
194
+ throw new Error(`fpm (${kind}) failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
195
+ }
196
+ return { format: kind, path: out, size: size(out) };
197
+ }
198
+
199
+ /** Build an AppDir and pack it into a single-file AppImage via appimagetool. */
200
+ async function buildAppImage(
201
+ input: LinuxPackageInput,
202
+ binName: string,
203
+ ): Promise<LinuxPackageResult> {
204
+ const appimagetool = resolveAppimagetool();
205
+ const appDirRoot = join(input.outDir, `.AppDir-${process.pid}`);
206
+ rmSync(appDirRoot, { recursive: true, force: true });
207
+
208
+ // Payload → AppDir/usr/lib/<id>/ so $ORIGIN resolves within it.
209
+ const payload = join(appDirRoot, "usr", "lib", input.bundleId);
210
+ mkdirSync(payload, { recursive: true });
211
+ cpSync(input.appDir, payload, { recursive: true });
212
+
213
+ // AppRun: exec the real host binary (resolves core/resources/helper from its dir).
214
+ const appRun = join(appDirRoot, "AppRun");
215
+ writeFileSync(
216
+ appRun,
217
+ [
218
+ "#!/bin/sh",
219
+ 'HERE="$(dirname "$(readlink -f "$0")")"',
220
+ `exec "$HERE/usr/lib/${input.bundleId}/${input.appName}" "$@"`,
221
+ "",
222
+ ].join("\n"),
223
+ );
224
+ chmodSync(appRun, 0o755);
225
+
226
+ // .desktop at the AppDir root (appimagetool reads it) + the standard location.
227
+ const desktop = buildDesktop(input, binName);
228
+ writeFileSync(join(appDirRoot, `${input.bundleId}.desktop`), desktop);
229
+ const appsDir = join(appDirRoot, "usr", "share", "applications");
230
+ mkdirSync(appsDir, { recursive: true });
231
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), desktop);
232
+
233
+ // Icon: <id>.png + .DirIcon at the root, plus the hicolor copy.
234
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
235
+ if (png) {
236
+ cpSync(png, join(appDirRoot, `${input.bundleId}.png`));
237
+ try {
238
+ symlinkSync(`${input.bundleId}.png`, join(appDirRoot, ".DirIcon"));
239
+ } catch {
240
+ /* best-effort: some filesystems disallow symlinks */
241
+ }
242
+ stageHicolorIcon(appDirRoot, input);
243
+ }
244
+
245
+ const arch = linuxPackageArch("appimage");
246
+ const fileName = `${input.appName}-${input.version}-${arch}.AppImage`;
247
+ const out = join(input.outDir, fileName);
248
+ rmSync(out, { force: true });
249
+
250
+ console.log(`[mirin] building AppImage → ${fileName}`);
251
+ // --appimage-extract-and-run: run appimagetool without FUSE. ARCH is required by
252
+ // appimagetool; NO_STRIP avoids stripping the Bun-compiled host (which would break it).
253
+ const res = await $`${appimagetool} --appimage-extract-and-run ${appDirRoot} ${out}`
254
+ .env({ ...process.env, ARCH: arch, NO_STRIP: "1" })
255
+ .nothrow()
256
+ .quiet();
257
+ rmSync(appDirRoot, { recursive: true, force: true });
258
+ if (res.exitCode !== 0 || !existsSync(out)) {
259
+ throw new Error(`appimagetool failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
260
+ }
261
+ chmodSync(out, 0o755);
262
+ return { format: "appimage", path: out, size: size(out) };
263
+ }
264
+
265
+ function linuxPackageArch(kind: "appimage" | "deb" | "rpm"): string {
266
+ if (process.arch === "arm64") {
267
+ return kind === "deb" ? "arm64" : "aarch64";
268
+ }
269
+ return kind === "deb" ? "amd64" : "x86_64";
270
+ }
271
+
272
+ /** Resolve the effective set of formats from config + an optional CLI/caller override. */
273
+ export function resolveLinuxFormats(
274
+ linux: boolean | LinuxConfig | undefined,
275
+ override?: LinuxPackageFormat[],
276
+ ): LinuxPackageFormat[] {
277
+ if (override && override.length) return validateLinuxFormats(override, "CLI override");
278
+ const fromConfig = typeof linux === "object" ? linux.formats : undefined;
279
+ return fromConfig && fromConfig.length
280
+ ? validateLinuxFormats(fromConfig, "mirin.config.ts linux.formats")
281
+ : [...LINUX_FORMATS];
282
+ }
283
+
284
+ export function parseLinuxFormats(raw: string): LinuxPackageFormat[] {
285
+ const formats = raw
286
+ .split(",")
287
+ .map((s) => s.trim())
288
+ .filter(Boolean);
289
+ if (!formats.length) {
290
+ throw new Error("[mirin] --linux-target needs at least one format: appimage, deb, or rpm.");
291
+ }
292
+ return validateLinuxFormats(formats, "--linux-target");
293
+ }
@@ -0,0 +1,30 @@
1
+ import type { LinuxConfig, LinuxPackageFormat } from "mirinjs";
2
+
3
+ export type { LinuxConfig, LinuxPackageFormat };
4
+
5
+ export interface LinuxPackageInput {
6
+ /** The assembled Linux app folder (build/<App>): the whole relocatable payload. */
7
+ appDir: string;
8
+ /** App display name; also the host-binary stem inside `appDir`. */
9
+ appName: string;
10
+ /** Reverse-DNS app id, e.g. "dev.netko.anko" (install prefix + `.desktop`/icon name). */
11
+ bundleId: string;
12
+ version: string;
13
+ /** Publisher / company (deb/rpm vendor + maintainer fallback). */
14
+ publisher: string;
15
+ /** Directory the artifacts are written into. */
16
+ outDir: string;
17
+ projectDir: string;
18
+ /** Abs path to the app icon source (`.png` / `.iconset`); resolved to a PNG. */
19
+ icon?: string;
20
+ /** Resolved Linux config (maintainer, depends, category, ...). */
21
+ options: LinuxConfig;
22
+ /** Which formats to build. */
23
+ formats: LinuxPackageFormat[];
24
+ }
25
+
26
+ export interface LinuxPackageResult {
27
+ format: LinuxPackageFormat;
28
+ path: string;
29
+ size: number;
30
+ }
@@ -0,0 +1,148 @@
1
+ import { rmSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { $ } from "bun";
4
+ import type { BuildResult } from "../build.ts";
5
+ import { buildDmg, type DmgOptions, notarizeAndStaple } from "../dmg.ts";
6
+ import { buildInnoInstaller, hasInno } from "../installer-inno.ts";
7
+ import { buildNsisInstaller, hasMakensis } from "../installer-win.ts";
8
+ import { buildLinuxPackages, resolveLinuxFormats } from "../package/linux/index.ts";
9
+
10
+ export interface ReleaseInstallerResult {
11
+ installerName?: string;
12
+ installerSize: number;
13
+ }
14
+
15
+ export interface ReleaseInstallerInput {
16
+ result: BuildResult;
17
+ buildDir: string;
18
+ outDir: string;
19
+ appArtifact: string;
20
+ prefix: string;
21
+ safeName: string;
22
+ isWindows: boolean;
23
+ isLinux: boolean;
24
+ }
25
+
26
+ /** Build first-install artifacts independently from updater compression and deltas. */
27
+ export async function buildReleaseInstaller(
28
+ input: ReleaseInstallerInput,
29
+ ): Promise<ReleaseInstallerResult> {
30
+ const { result, isWindows, isLinux } = input;
31
+ if (isWindows) return buildWindowsInstaller(input);
32
+ if (isLinux) return buildLinuxInstallers(input);
33
+ if (result.dmg !== false) return buildMacInstaller(input);
34
+ return { installerSize: 0 };
35
+ }
36
+
37
+ async function buildWindowsInstaller(
38
+ input: ReleaseInstallerInput,
39
+ ): Promise<ReleaseInstallerResult> {
40
+ const { result, buildDir, outDir, appArtifact, prefix, safeName } = input;
41
+ const setupName = `${prefix}-${safeName}-setup.exe`;
42
+ const installerArgs = {
43
+ appDir: result.app,
44
+ appName: result.appName,
45
+ exeName: `${result.appName}.exe`,
46
+ version: result.version,
47
+ bundleId: result.bundleId,
48
+ outDir,
49
+ fileName: setupName,
50
+ projectDir: result.projectDir,
51
+ };
52
+ const innoWanted = result.inno !== false;
53
+ const nsisWanted = result.nsis !== false;
54
+ const innoOpts = typeof result.inno === "object" ? result.inno : {};
55
+ const nsisOpts = typeof result.nsis === "object" ? result.nsis : {};
56
+ let exePath: string | undefined;
57
+
58
+ if (innoWanted && hasInno()) {
59
+ exePath = await buildInnoInstaller({
60
+ ...installerArgs,
61
+ options: { ...innoOpts, publisher: innoOpts.publisher ?? result.publisher },
62
+ });
63
+ } else if (nsisWanted && (await hasMakensis())) {
64
+ exePath = await buildNsisInstaller({
65
+ ...installerArgs,
66
+ options: { ...nsisOpts, publisher: nsisOpts.publisher ?? result.publisher },
67
+ });
68
+ }
69
+
70
+ if (exePath) return { installerName: setupName, installerSize: statSync(exePath).size };
71
+ if (innoWanted || nsisWanted) {
72
+ console.warn(
73
+ "[mirin release] no installer toolchain found — shipping the portable .zip. " +
74
+ "Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
75
+ );
76
+ }
77
+
78
+ const installerName = `${prefix}-${safeName}.zip`;
79
+ const zipPath = join(outDir, installerName);
80
+ console.log(`[mirin release] zipping → ${installerName}`);
81
+ rmSync(zipPath, { force: true });
82
+ await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
83
+ return { installerName, installerSize: statSync(zipPath).size };
84
+ }
85
+
86
+ async function buildLinuxInstallers(input: ReleaseInstallerInput): Promise<ReleaseInstallerResult> {
87
+ const { result, outDir } = input;
88
+ if (result.linux === false) return { installerSize: 0 };
89
+
90
+ try {
91
+ const formats = resolveLinuxFormats(result.linux);
92
+ console.log(`[mirin release] building Linux packages (${formats.join(", ")})…`);
93
+ const packages = await buildLinuxPackages({
94
+ appDir: result.app,
95
+ appName: result.appName,
96
+ bundleId: result.bundleId,
97
+ version: result.version,
98
+ publisher: result.publisher,
99
+ outDir,
100
+ projectDir: result.projectDir,
101
+ icon: result.icon,
102
+ options: typeof result.linux === "object" ? result.linux : {},
103
+ formats,
104
+ });
105
+ const primary = packages.find((artifact) => artifact.format === "appimage") ?? packages[0];
106
+ for (const artifact of packages) {
107
+ if (artifact !== primary) {
108
+ console.log(` ${basename(artifact.path)} (${(artifact.size / 1e6).toFixed(1)} MB)`);
109
+ }
110
+ }
111
+ return primary
112
+ ? { installerName: basename(primary.path), installerSize: primary.size }
113
+ : { installerSize: 0 };
114
+ } catch (error) {
115
+ console.warn(
116
+ `\n[mirin release] Linux packaging failed: ${error instanceof Error ? error.message : error}\n` +
117
+ "[mirin release] shipping the updater bundle + manifest WITHOUT packages.\n",
118
+ );
119
+ return { installerSize: 0 };
120
+ }
121
+ }
122
+
123
+ async function buildMacInstaller(input: ReleaseInstallerInput): Promise<ReleaseInstallerResult> {
124
+ const { result, outDir, prefix, safeName } = input;
125
+ const installerName = `${prefix}-${safeName}.dmg`;
126
+ console.log(`[mirin release] building installer → ${installerName}`);
127
+ try {
128
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
129
+ const path = await buildDmg({
130
+ app: result.app,
131
+ appName: result.appName,
132
+ outDir,
133
+ fileName: installerName,
134
+ options,
135
+ projectDir: result.projectDir,
136
+ signIdentity: result.signIdentity,
137
+ });
138
+ await notarizeAndStaple(path);
139
+ return { installerName, installerSize: statSync(path).size };
140
+ } catch (error) {
141
+ console.warn(
142
+ `\n[mirin release] DMG build failed: ${error instanceof Error ? error.message : error}\n` +
143
+ "[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n",
144
+ );
145
+ rmSync(join(outDir, installerName), { force: true });
146
+ return { installerSize: 0 };
147
+ }
148
+ }
@@ -0,0 +1,105 @@
1
+ export interface PreviousReleaseManifest {
2
+ version: string;
3
+ bundle: {
4
+ url: string;
5
+ sha256: string;
6
+ size: number;
7
+ };
8
+ }
9
+
10
+ const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024 * 1024;
11
+
12
+ export function parsePreviousReleaseManifest(
13
+ value: unknown,
14
+ expected: { channel: string; platform: string; arch: string },
15
+ ): PreviousReleaseManifest {
16
+ const manifest = record(value, "manifest");
17
+ for (const key of ["channel", "platform", "arch"] as const) {
18
+ if (stringField(manifest, key) !== expected[key]) {
19
+ throw new Error(`previous update manifest target mismatch: ${key}`);
20
+ }
21
+ }
22
+
23
+ const version = stringField(manifest, "version");
24
+ if (
25
+ version.length > 128 ||
26
+ !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
27
+ version,
28
+ )
29
+ ) {
30
+ throw new Error("invalid previous update version");
31
+ }
32
+
33
+ const bundle = record(manifest.bundle, "bundle");
34
+ const url = stringField(bundle, "url");
35
+ releaseArtifactUrl("https://example.invalid", url);
36
+ const sha256 = stringField(bundle, "sha256").toLowerCase();
37
+ if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("invalid previous bundle checksum");
38
+ const size = bundle.size;
39
+ if (
40
+ typeof size !== "number" ||
41
+ !Number.isSafeInteger(size) ||
42
+ size <= 0 ||
43
+ size > MAX_ARTIFACT_BYTES
44
+ ) {
45
+ throw new Error("invalid previous bundle size");
46
+ }
47
+
48
+ return { version, bundle: { url, sha256, size } };
49
+ }
50
+
51
+ export function trustedReleaseBaseUrl(raw: string): string {
52
+ let url: URL;
53
+ try {
54
+ url = new URL(raw);
55
+ } catch {
56
+ throw new Error(`invalid release baseUrl: ${raw}`);
57
+ }
58
+ assertTrustedReleaseUrl(url.toString());
59
+ url.hash = "";
60
+ url.search = "";
61
+ return url.toString().replace(/\/$/, "");
62
+ }
63
+
64
+ export function assertTrustedReleaseUrl(raw: string): void {
65
+ const url = new URL(raw);
66
+ if (url.protocol === "https:") return;
67
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return;
68
+ throw new Error("release URLs must use HTTPS, except loopback HTTP for local testing");
69
+ }
70
+
71
+ export function releaseArtifactUrl(base: string, fileName: string): string {
72
+ if (
73
+ fileName.length === 0 ||
74
+ fileName.length > 255 ||
75
+ fileName.includes("/") ||
76
+ fileName.includes("\\") ||
77
+ fileName.includes("?") ||
78
+ fileName.includes("#") ||
79
+ fileName === "." ||
80
+ fileName === ".."
81
+ ) {
82
+ throw new Error(`unsafe previous update artifact name: ${fileName}`);
83
+ }
84
+ return `${base}/${fileName}`;
85
+ }
86
+
87
+ function record(value: unknown, label: string): Record<string, unknown> {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
89
+ throw new Error(`invalid previous update ${label}`);
90
+ }
91
+ return value as Record<string, unknown>;
92
+ }
93
+
94
+ function stringField(source: Record<string, unknown>, key: string): string {
95
+ const value = source[key];
96
+ if (typeof value !== "string" || value.length === 0) {
97
+ throw new Error(`invalid previous update field: ${key}`);
98
+ }
99
+ return value;
100
+ }
101
+
102
+ function isLoopbackHost(hostname: string): boolean {
103
+ const host = hostname.toLowerCase();
104
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
105
+ }