@mirinjs/cli 0.0.1-alpha.9 → 0.1.0-alpha.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/src/release.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `mirin release` — build the app and emit flat-named update artifacts.
3
+ *
4
+ * Produces, under `build/release/`:
5
+ * {channel}-{platform}-{arch}-update.json the manifest the app polls
6
+ * {channel}-{platform}-{arch}-{Name}.app.tar.zst the full bundle (fallback)
7
+ * {channel}-{platform}-{arch}-{prevVersion}.patch delta from the previous release
8
+ *
9
+ * The bundle is a zstd-compressed tar of the whole signed `.app`. Identity is the
10
+ * SHA-256 of the *uncompressed* tar (`tarHash`), so a delta patch can reconstruct
11
+ * it exactly. When the previous release is reachable at `baseUrl`, a bsdiff patch
12
+ * (prev → this) is generated; the app applies it instead of re-downloading the
13
+ * whole bundle, falling back to the full bundle whenever a patch isn't usable.
14
+ *
15
+ * Names are flat (no folders) so they upload as-is to GitHub Releases, S3/R2, or
16
+ * any static host. Channels coexist because the channel is part of every name.
17
+ */
18
+
19
+ import { $ } from "bun";
20
+ import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import { build } from "./build.ts";
24
+ import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
+ import { loadCodec } from "mirinjs/codec";
26
+
27
+ const sha256File = (path: string) =>
28
+ new Bun.CryptoHasher("sha256").update(readFileSync(path)).digest("hex");
29
+
30
+ export async function release(projectDir = process.cwd()): Promise<number> {
31
+ const result = await build(projectDir);
32
+ if (!result.baseUrl) {
33
+ console.error("[mirin release] no `release.baseUrl` in mirin.config.ts — nothing to publish.");
34
+ return 1;
35
+ }
36
+
37
+ const isWindows = process.platform === "win32";
38
+ const platform = isWindows ? "win32" : "darwin";
39
+ const arch = process.arch; // "arm64" | "x64"
40
+ const prefix = `${result.channel}-${platform}-${arch}`;
41
+ const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
42
+ const base = result.baseUrl.replace(/\/$/, "");
43
+ // The packaged unit: a flat app folder on Windows, an `.app` bundle on macOS.
44
+ const appArtifact = isWindows ? result.appName : `${result.appName}.app`;
45
+
46
+ const buildDir = join(projectDir, "build");
47
+ const outDir = join(buildDir, "release");
48
+ rmSync(outDir, { recursive: true, force: true });
49
+ mkdirSync(outDir, { recursive: true });
50
+
51
+ // Notarize + staple the .app (Developer ID, macOS) before packing, when
52
+ // credentials are present. The .tar.zst / patch updater bundles are made from
53
+ // this signed, stapled .app, so the updater swaps in an already-notarized app.
54
+ if (!isWindows) await notarizeAndStaple(result.app);
55
+
56
+ // Load the codec (zstd/bsdiff) from the *bundled* core, not the raw build output:
57
+ // on Windows mirin_core.dll imports libcef.dll. The DLL loader resolves imports
58
+ // from PATH (and the loader exe's dir), not the dll's own dir, so prepend the
59
+ // bundle dir to PATH so libcef.dll is found. (On macOS the framework loads at
60
+ // runtime, so result.coreDylib works directly.)
61
+ const codecCore = isWindows ? join(result.app, "mirin_core.dll") : result.coreDylib;
62
+ if (isWindows) process.env.PATH = `${result.app};${process.env.PATH ?? ""}`;
63
+ const codec = loadCodec(codecCore);
64
+
65
+ // Uncompressed tar — the identity + diff/patch basis (BSD tar keeps symlinks).
66
+ const newTar = join(outDir, "_new.tar");
67
+ await $`tar -cf ${newTar} -C ${buildDir} ${appArtifact}`;
68
+ const tarHash = sha256File(newTar);
69
+
70
+ // Full bundle: zstd(newTar).
71
+ const bundleName = `${prefix}-${safeName}${isWindows ? "" : ".app"}.tar.zst`;
72
+ const bundlePath = join(outDir, bundleName);
73
+ console.log(`[mirin release] compressing → ${bundleName}`);
74
+ codec.compress(newTar, bundlePath, 19);
75
+ const bundleSha = sha256File(bundlePath);
76
+ const bundleSize = readFileSync(bundlePath).byteLength;
77
+
78
+ // Delta patch vs the previous release (if reachable). Best-effort.
79
+ const patches: Array<{ fromVersion: string; url: string; sha256: string; size: number }> = [];
80
+ try {
81
+ const prevRes = await fetch(`${base}/${prefix}-update.json?t=${Date.now()}`, { redirect: "follow" });
82
+ if (prevRes.ok) {
83
+ const prev = (await prevRes.json()) as { version: string; bundle?: { url: string } };
84
+ if (prev.version && prev.version !== result.version && prev.bundle?.url) {
85
+ console.log(`[mirin release] generating delta ${prev.version} → ${result.version}…`);
86
+ const tmp = join(tmpdir(), `mirin-release-${Date.now()}`);
87
+ mkdirSync(tmp, { recursive: true });
88
+ const prevZst = join(tmp, "prev.tar.zst");
89
+ const dl = await fetch(`${base}/${prev.bundle.url}`, { redirect: "follow" });
90
+ if (!dl.ok || !dl.body) throw new Error(`prev bundle ${dl.status}`);
91
+ await Bun.write(prevZst, await dl.arrayBuffer());
92
+ const prevTar = join(tmp, "prev.tar");
93
+ codec.decompress(prevZst, prevTar);
94
+ const rawPatch = join(tmp, "patch.bin");
95
+ codec.diff(prevTar, newTar, rawPatch); // bsdiff
96
+ const patchName = `${prefix}-${prev.version}.patch`;
97
+ const patchPath = join(outDir, patchName);
98
+ codec.compress(rawPatch, patchPath, 19);
99
+ rmSync(tmp, { recursive: true, force: true });
100
+ patches.push({
101
+ fromVersion: prev.version,
102
+ url: patchName,
103
+ sha256: sha256File(patchPath),
104
+ size: readFileSync(patchPath).byteLength,
105
+ });
106
+ }
107
+ }
108
+ } catch (e) {
109
+ console.warn(`[mirin release] skipping delta patch: ${e instanceof Error ? e.message : e}`);
110
+ }
111
+
112
+ rmSync(newTar, { force: true });
113
+
114
+ const manifest = {
115
+ version: result.version,
116
+ channel: result.channel,
117
+ platform,
118
+ arch,
119
+ tarHash,
120
+ bundle: { url: bundleName, sha256: bundleSha, size: bundleSize },
121
+ patches,
122
+ };
123
+ const manifestName = `${prefix}-update.json`;
124
+ await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
125
+
126
+ // Distributable installer for first-time installs (the updater uses the
127
+ // .tar.zst/patch): a drag-to-Applications .dmg on macOS, a .zip of the app
128
+ // folder on Windows.
129
+ let installerName: string | undefined;
130
+ let installerSize = 0;
131
+ if (isWindows) {
132
+ installerName = `${prefix}-${safeName}.zip`;
133
+ console.log(`[mirin release] zipping installer → ${installerName}`);
134
+ const zipPath = join(outDir, installerName);
135
+ rmSync(zipPath, { force: true });
136
+ // bsdtar (Windows `tar`) creates a .zip from the extension with `-a`.
137
+ await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
138
+ installerSize = readFileSync(zipPath).byteLength;
139
+ } else if (result.dmg !== false) {
140
+ installerName = `${prefix}-${safeName}.dmg`;
141
+ console.log(`[mirin release] building installer → ${installerName}`);
142
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
143
+ const dmgPath = await buildDmg({
144
+ app: result.app,
145
+ appName: result.appName,
146
+ outDir,
147
+ fileName: installerName,
148
+ options,
149
+ projectDir: result.projectDir,
150
+ signIdentity: result.signIdentity,
151
+ });
152
+ await notarizeAndStaple(dmgPath); // no-op without notary credentials
153
+ installerSize = readFileSync(dmgPath).byteLength;
154
+ }
155
+
156
+ const mb = (n: number) => (n / 1e6).toFixed(1);
157
+ console.log(`\n[mirin release] done → build/release/`);
158
+ console.log(` ${manifestName}`);
159
+ console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
160
+ for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
161
+ if (installerName) console.log(` ${installerName} (${mb(installerSize)} MB installer)`);
162
+ console.log(`\nUpload all of build/release/ to: ${result.baseUrl}`);
163
+ if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
164
+ return 0;
165
+ }
package/src/temps.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Remove `bun build --compile` scratch files (`.*.bun-build`) from a directory.
3
+ *
4
+ * `bun build --compile` writes these temp files into the working directory and
5
+ * normally cleans them up — but a hard-killed build leaves them behind, where
6
+ * they accumulate in the project root. The dev/build commands sweep them first.
7
+ */
8
+
9
+ import { readdirSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ export function sweepBuildTemps(dir: string): void {
13
+ let entries: string[];
14
+ try {
15
+ entries = readdirSync(dir);
16
+ } catch {
17
+ return;
18
+ }
19
+ for (const name of entries) {
20
+ if (name.endsWith(".bun-build")) {
21
+ rmSync(join(dir, name), { force: true });
22
+ }
23
+ }
24
+ }