@mirinjs/cli 0.0.1-alpha.11 → 0.0.1-alpha.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.0.1-alpha.11",
3
+ "version": "0.0.1-alpha.12",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,10 +27,11 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.0.1-alpha.11"
30
+ "create-mirinjs": "0.0.1-alpha.12",
31
+ "mirinjs": "0.0.1-alpha.12"
31
32
  },
32
33
  "optionalDependencies": {
33
- "@mirinjs/darwin-arm64": "0.0.1-alpha.11"
34
+ "@mirinjs/darwin-arm64": "0.0.1-alpha.12"
34
35
  },
35
36
  "publishConfig": {
36
37
  "access": "public"
package/src/build.ts CHANGED
@@ -33,6 +33,8 @@ export interface BuildResult {
33
33
  channel: string;
34
34
  /** Update baseUrl, if `release` is configured. */
35
35
  baseUrl?: string;
36
+ /** libmirin_core path (for the updater codec at release time). */
37
+ coreDylib: string;
36
38
  }
37
39
 
38
40
  /** Read the project's package.json version (the single source of app version). */
@@ -106,5 +108,5 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
106
108
 
107
109
  console.log(`\n[mirin build] done → ${app}`);
108
110
  console.log(` open "${app}"`);
109
- return { app, appName, bundleId, version, channel, baseUrl };
111
+ return { app, appName, bundleId, version, channel, baseUrl, coreDylib: artifacts.coreDylib };
110
112
  }
package/src/release.ts CHANGED
@@ -2,46 +2,49 @@
2
2
  * `mirin release` — build the app and emit flat-named update artifacts.
3
3
  *
4
4
  * Produces, under `build/release/`:
5
- * {channel}-{platform}-{arch}-update.json the manifest the app polls
6
- * {channel}-{platform}-{arch}-{Name}.app.tar.gz the full bundle
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
7
8
  *
8
- * These names are flat (no folders) so they upload as-is to GitHub Releases,
9
- * S3/R2, or any static host — whatever `release.baseUrl` points at. Channels
10
- * coexist at the same baseUrl because the channel is part of every filename.
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.
11
14
  *
12
- * The app's `app.updater` polls `{baseUrl}/{prefix}-update.json`, compares the
13
- * advertised version to its own, downloads `url`, verifies `sha256`, then swaps
14
- * the whole `.app` and relaunches. (A signed/notarized `.app` must be replaced
15
- * whole — never modified in place — so the artifact is the entire bundle.)
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.
16
17
  */
17
18
 
18
19
  import { $ } from "bun";
19
- import { mkdirSync, rmSync, readFileSync } from "node:fs";
20
+ import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
20
21
  import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
21
23
  import { build } from "./build.ts";
24
+ import { loadCodec } from "mirinjs/codec";
25
+
26
+ const sha256File = (path: string) =>
27
+ new Bun.CryptoHasher("sha256").update(readFileSync(path)).digest("hex");
22
28
 
23
29
  export async function release(projectDir = process.cwd()): Promise<number> {
24
30
  const result = await build(projectDir);
25
31
  if (!result.baseUrl) {
26
- console.error(
27
- "[mirin release] no `release.baseUrl` in mirin.config.ts — nothing to publish.",
28
- );
32
+ console.error("[mirin release] no `release.baseUrl` in mirin.config.ts — nothing to publish.");
29
33
  return 1;
30
34
  }
31
35
 
32
36
  const platform = "darwin";
33
37
  const arch = process.arch; // "arm64" | "x64"
34
38
  const prefix = `${result.channel}-${platform}-${arch}`;
35
- // Sanitize the app name for a URL-safe artifact filename (spaces break hosts).
36
39
  const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
40
+ const base = result.baseUrl.replace(/\/$/, "");
37
41
 
38
42
  const buildDir = join(projectDir, "build");
39
43
  const outDir = join(buildDir, "release");
40
44
  rmSync(outDir, { recursive: true, force: true });
41
45
  mkdirSync(outDir, { recursive: true });
42
46
 
43
- // Notarize + staple before packing (so the shipped bundle passes Gatekeeper on
44
- // end-user machines), when notary credentials are present in the environment.
47
+ // Optional notarize + staple (Developer ID) before packing, when configured.
45
48
  const apple = process.env.MIRIN_NOTARY_APPLE_ID;
46
49
  const pw = process.env.MIRIN_NOTARY_PASSWORD;
47
50
  const team = process.env.MIRIN_NOTARY_TEAM_ID;
@@ -54,32 +57,75 @@ export async function release(projectDir = process.cwd()): Promise<number> {
54
57
  rmSync(zip, { force: true });
55
58
  }
56
59
 
57
- const tarName = `${prefix}-${safeName}.app.tar.gz`;
58
- const tarPath = join(outDir, tarName);
60
+ const codec = loadCodec(result.coreDylib);
61
+
62
+ // Uncompressed tar — the identity + diff/patch basis (BSD tar keeps symlinks).
63
+ const newTar = join(outDir, "_new.tar");
64
+ await $`tar -cf ${newTar} -C ${buildDir} ${`${result.appName}.app`}`;
65
+ const tarHash = sha256File(newTar);
59
66
 
60
- console.log(`[mirin release] packing ${result.appName}.app → ${tarName}`);
61
- // BSD tar preserves the CEF framework's symlinks + executable bits.
62
- await $`tar -czf ${tarPath} -C ${buildDir} ${`${result.appName}.app`}`;
67
+ // Full bundle: zstd(newTar).
68
+ const bundleName = `${prefix}-${safeName}.app.tar.zst`;
69
+ const bundlePath = join(outDir, bundleName);
70
+ console.log(`[mirin release] compressing → ${bundleName}`);
71
+ codec.compress(newTar, bundlePath, 19);
72
+ const bundleSha = sha256File(bundlePath);
73
+ const bundleSize = readFileSync(bundlePath).byteLength;
74
+
75
+ // Delta patch vs the previous release (if reachable). Best-effort.
76
+ const patches: Array<{ fromVersion: string; url: string; sha256: string; size: number }> = [];
77
+ try {
78
+ const prevRes = await fetch(`${base}/${prefix}-update.json?t=${Date.now()}`, { redirect: "follow" });
79
+ if (prevRes.ok) {
80
+ const prev = (await prevRes.json()) as { version: string; bundle?: { url: string } };
81
+ if (prev.version && prev.version !== result.version && prev.bundle?.url) {
82
+ console.log(`[mirin release] generating delta ${prev.version} → ${result.version}…`);
83
+ const tmp = join(tmpdir(), `mirin-release-${Date.now()}`);
84
+ mkdirSync(tmp, { recursive: true });
85
+ const prevZst = join(tmp, "prev.tar.zst");
86
+ const dl = await fetch(`${base}/${prev.bundle.url}`, { redirect: "follow" });
87
+ if (!dl.ok || !dl.body) throw new Error(`prev bundle ${dl.status}`);
88
+ await Bun.write(prevZst, await dl.arrayBuffer());
89
+ const prevTar = join(tmp, "prev.tar");
90
+ codec.decompress(prevZst, prevTar);
91
+ const rawPatch = join(tmp, "patch.bin");
92
+ codec.diff(prevTar, newTar, rawPatch); // bsdiff
93
+ const patchName = `${prefix}-${prev.version}.patch`;
94
+ const patchPath = join(outDir, patchName);
95
+ codec.compress(rawPatch, patchPath, 19);
96
+ rmSync(tmp, { recursive: true, force: true });
97
+ patches.push({
98
+ fromVersion: prev.version,
99
+ url: patchName,
100
+ sha256: sha256File(patchPath),
101
+ size: readFileSync(patchPath).byteLength,
102
+ });
103
+ }
104
+ }
105
+ } catch (e) {
106
+ console.warn(`[mirin release] skipping delta patch: ${e instanceof Error ? e.message : e}`);
107
+ }
63
108
 
64
- const bytes = readFileSync(tarPath);
65
- const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
109
+ rmSync(newTar, { force: true });
66
110
 
67
111
  const manifest = {
68
112
  version: result.version,
69
113
  channel: result.channel,
70
114
  platform,
71
115
  arch,
72
- url: tarName,
73
- sha256,
74
- size: bytes.byteLength,
116
+ tarHash,
117
+ bundle: { url: bundleName, sha256: bundleSha, size: bundleSize },
118
+ patches,
75
119
  };
76
120
  const manifestName = `${prefix}-update.json`;
77
121
  await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
78
122
 
79
- const mb = (bytes.byteLength / 1e6).toFixed(1);
123
+ const mb = (n: number) => (n / 1e6).toFixed(1);
80
124
  console.log(`\n[mirin release] done → build/release/`);
81
125
  console.log(` ${manifestName}`);
82
- console.log(` ${tarName} (${mb} MB)`);
83
- console.log(`\nUpload both to: ${result.baseUrl}`);
126
+ console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
127
+ for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
128
+ console.log(`\nUpload all of build/release/ to: ${result.baseUrl}`);
129
+ if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
84
130
  return 0;
85
131
  }