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

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.13",
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.13",
31
+ "mirinjs": "0.0.1-alpha.13"
31
32
  },
32
33
  "optionalDependencies": {
33
- "@mirinjs/darwin-arm64": "0.0.1-alpha.11"
34
+ "@mirinjs/darwin-arm64": "0.0.1-alpha.13"
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/bundle.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { $ } from "bun";
15
- import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
15
+ import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
 
18
18
  const FRAMEWORK = "Chromium Embedded Framework.framework";
@@ -208,14 +208,66 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
208
208
  );
209
209
  }
210
210
 
211
- // Sign inside-out: framework, helpers, then the outer app. Ad-hoc ("-") by
212
- // default; pass a Developer ID to produce a distributable, notarizable app.
211
+ // Sign inside-out. Ad-hoc ("-") by default for local builds; pass a Developer
212
+ // ID to produce a distributable, notarizable app. Notarization requires the
213
+ // hardened runtime (--options runtime), a secure timestamp (--timestamp), and
214
+ // entitlements that let CEF + Bun JIT and load unsigned executable memory —
215
+ // without all three the Apple notary service returns "Invalid".
213
216
  const identity = opts.signIdentity ?? "-";
214
- await $`codesign --force --sign ${identity} ${join(frameworks, FRAMEWORK)}`.quiet();
215
- for (const { suffix } of HELPER_TYPES) {
216
- await $`codesign --force --sign ${identity} ${join(frameworks, `${appName} Helper${suffix}.app`)}`.quiet();
217
+ const notarizable = identity !== "-";
218
+ const cef = join(frameworks, FRAMEWORK);
219
+
220
+ if (notarizable) {
221
+ // Mirrors the entitlement set Electrobun ships for Bun + CEF under hardened
222
+ // runtime (electrobun-reference/package/src/cli/index.ts).
223
+ const entitlements = join(opts.outDir, "_entitlements.plist");
224
+ writeFileSync(
225
+ entitlements,
226
+ `<?xml version="1.0" encoding="UTF-8"?>
227
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
228
+ <plist version="1.0">
229
+ <dict>
230
+ <key>com.apple.security.cs.allow-jit</key><true/>
231
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
232
+ <key>com.apple.security.cs.disable-library-validation</key><true/>
233
+ </dict>
234
+ </plist>
235
+ `,
236
+ );
237
+ const sign = (path: string, ents = false) =>
238
+ ents
239
+ ? $`codesign --force --timestamp --options runtime --entitlements ${entitlements} --sign ${identity} ${path}`.quiet()
240
+ : $`codesign --force --timestamp --options runtime --sign ${identity} ${path}`.quiet();
241
+
242
+ // 1. CEF's nested libraries, then 2. the framework bundle itself.
243
+ const cefLibs = join(cef, "Libraries");
244
+ if (existsSync(cefLibs)) {
245
+ for (const lib of readdirSync(cefLibs)) {
246
+ if (lib.endsWith(".dylib")) await sign(join(cefLibs, lib));
247
+ }
248
+ }
249
+ await sign(cef);
250
+ // 3. our FFI core dylib.
251
+ await sign(join(macos, "libmirin_core.dylib"));
252
+ // 4. each helper: the inner executable, then the .app wrapper (entitlements
253
+ // on both — the renderer/GPU helpers are what actually JIT).
254
+ for (const { suffix } of HELPER_TYPES) {
255
+ const name = `${appName} Helper${suffix}`;
256
+ const helperApp = join(frameworks, `${name}.app`);
257
+ await sign(join(helperApp, "Contents", "MacOS", name), true);
258
+ await sign(helperApp, true);
259
+ }
260
+ // 5. finally the outer app.
261
+ await sign(app, true);
262
+ rmSync(entitlements, { force: true });
263
+ } else {
264
+ // Ad-hoc: enough to launch locally; not distributable or notarizable.
265
+ await $`codesign --force --sign ${identity} ${cef}`.quiet();
266
+ for (const { suffix } of HELPER_TYPES) {
267
+ await $`codesign --force --sign ${identity} ${join(frameworks, `${appName} Helper${suffix}.app`)}`.quiet();
268
+ }
269
+ await $`codesign --force --sign ${identity} ${app}`.quiet();
217
270
  }
218
- await $`codesign --force --sign ${identity} ${app}`.quiet();
219
271
 
220
272
  return { app, exe: join(macos, appName) };
221
273
  }
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;
@@ -49,37 +52,101 @@ export async function release(projectDir = process.cwd()): Promise<number> {
49
52
  console.log("[mirin release] notarizing (this can take a few minutes)…");
50
53
  const zip = join(buildDir, "_notarize.zip");
51
54
  await $`ditto -c -k --keepParent ${result.app} ${zip}`;
52
- await $`xcrun notarytool submit ${zip} --apple-id ${apple} --password ${pw} --team-id ${team} --wait`;
53
- await $`xcrun stapler staple ${result.app}`;
55
+ // `notarytool submit --wait` exits 0 even when the result is "Invalid", so
56
+ // parse the JSON status ourselves and surface the notary log on rejection —
57
+ // otherwise the only symptom is a confusing `stapler` failure downstream.
58
+ const out =
59
+ await $`xcrun notarytool submit ${zip} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
54
60
  rmSync(zip, { force: true });
61
+ let sub: { id?: string; status?: string } = {};
62
+ try {
63
+ sub = JSON.parse(out);
64
+ } catch {
65
+ console.error(out);
66
+ }
67
+ if (sub.status !== "Accepted") {
68
+ console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
69
+ if (sub.id) {
70
+ const log =
71
+ await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
72
+ .text()
73
+ .catch(() => "");
74
+ if (log) console.error(log);
75
+ }
76
+ throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
77
+ }
78
+ await $`xcrun stapler staple ${result.app}`;
55
79
  }
56
80
 
57
- const tarName = `${prefix}-${safeName}.app.tar.gz`;
58
- const tarPath = join(outDir, tarName);
81
+ const codec = loadCodec(result.coreDylib);
59
82
 
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`}`;
83
+ // Uncompressed tar the identity + diff/patch basis (BSD tar keeps symlinks).
84
+ const newTar = join(outDir, "_new.tar");
85
+ await $`tar -cf ${newTar} -C ${buildDir} ${`${result.appName}.app`}`;
86
+ const tarHash = sha256File(newTar);
87
+
88
+ // Full bundle: zstd(newTar).
89
+ const bundleName = `${prefix}-${safeName}.app.tar.zst`;
90
+ const bundlePath = join(outDir, bundleName);
91
+ console.log(`[mirin release] compressing → ${bundleName}`);
92
+ codec.compress(newTar, bundlePath, 19);
93
+ const bundleSha = sha256File(bundlePath);
94
+ const bundleSize = readFileSync(bundlePath).byteLength;
95
+
96
+ // Delta patch vs the previous release (if reachable). Best-effort.
97
+ const patches: Array<{ fromVersion: string; url: string; sha256: string; size: number }> = [];
98
+ try {
99
+ const prevRes = await fetch(`${base}/${prefix}-update.json?t=${Date.now()}`, { redirect: "follow" });
100
+ if (prevRes.ok) {
101
+ const prev = (await prevRes.json()) as { version: string; bundle?: { url: string } };
102
+ if (prev.version && prev.version !== result.version && prev.bundle?.url) {
103
+ console.log(`[mirin release] generating delta ${prev.version} → ${result.version}…`);
104
+ const tmp = join(tmpdir(), `mirin-release-${Date.now()}`);
105
+ mkdirSync(tmp, { recursive: true });
106
+ const prevZst = join(tmp, "prev.tar.zst");
107
+ const dl = await fetch(`${base}/${prev.bundle.url}`, { redirect: "follow" });
108
+ if (!dl.ok || !dl.body) throw new Error(`prev bundle ${dl.status}`);
109
+ await Bun.write(prevZst, await dl.arrayBuffer());
110
+ const prevTar = join(tmp, "prev.tar");
111
+ codec.decompress(prevZst, prevTar);
112
+ const rawPatch = join(tmp, "patch.bin");
113
+ codec.diff(prevTar, newTar, rawPatch); // bsdiff
114
+ const patchName = `${prefix}-${prev.version}.patch`;
115
+ const patchPath = join(outDir, patchName);
116
+ codec.compress(rawPatch, patchPath, 19);
117
+ rmSync(tmp, { recursive: true, force: true });
118
+ patches.push({
119
+ fromVersion: prev.version,
120
+ url: patchName,
121
+ sha256: sha256File(patchPath),
122
+ size: readFileSync(patchPath).byteLength,
123
+ });
124
+ }
125
+ }
126
+ } catch (e) {
127
+ console.warn(`[mirin release] skipping delta patch: ${e instanceof Error ? e.message : e}`);
128
+ }
63
129
 
64
- const bytes = readFileSync(tarPath);
65
- const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
130
+ rmSync(newTar, { force: true });
66
131
 
67
132
  const manifest = {
68
133
  version: result.version,
69
134
  channel: result.channel,
70
135
  platform,
71
136
  arch,
72
- url: tarName,
73
- sha256,
74
- size: bytes.byteLength,
137
+ tarHash,
138
+ bundle: { url: bundleName, sha256: bundleSha, size: bundleSize },
139
+ patches,
75
140
  };
76
141
  const manifestName = `${prefix}-update.json`;
77
142
  await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
78
143
 
79
- const mb = (bytes.byteLength / 1e6).toFixed(1);
144
+ const mb = (n: number) => (n / 1e6).toFixed(1);
80
145
  console.log(`\n[mirin release] done → build/release/`);
81
146
  console.log(` ${manifestName}`);
82
- console.log(` ${tarName} (${mb} MB)`);
83
- console.log(`\nUpload both to: ${result.baseUrl}`);
147
+ console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
148
+ for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
149
+ console.log(`\nUpload all of build/release/ to: ${result.baseUrl}`);
150
+ if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
84
151
  return 0;
85
152
  }