create-zenbu-app 0.0.29 → 0.0.32

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,847 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import fs from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import fsp from "node:fs/promises";
7
+ import https from "node:https";
8
+ import crypto from "node:crypto";
9
+ //#region src/desktop/electron-cache.ts
10
+ /**
11
+ * Layout under `~/.zenbu/electron/`:
12
+ *
13
+ * versions/<major>/<exact-version>/Electron.app
14
+ * versions/<major>/.current -> "<exact-version>" (text marker)
15
+ * .locks/<major>.lock -> flock target during download
16
+ * .downloads/<exact>.zip -> intermediate, deleted on success
17
+ */
18
+ const ROOT = path.join(os.homedir(), ".zenbu", "electron");
19
+ async function ensureElectronApp(opts) {
20
+ const { log } = opts;
21
+ const exact = await resolveExactVersion(opts.versionRange, log);
22
+ const major = parseInt(exact.split(".")[0], 10);
23
+ if (!Number.isFinite(major)) throw new Error(`failed to parse major from electron version "${exact}"`);
24
+ const versionDir = path.join(ROOT, "versions", String(major), exact);
25
+ const electronAppPath = path.join(versionDir, "Electron.app");
26
+ if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
27
+ log.info(`cache hit: ${electronAppPath}`);
28
+ return {
29
+ electronAppPath,
30
+ version: exact,
31
+ major
32
+ };
33
+ }
34
+ await fsp.mkdir(path.join(ROOT, ".locks"), { recursive: true });
35
+ await fsp.mkdir(path.join(ROOT, ".downloads"), { recursive: true });
36
+ await withLock(path.join(ROOT, ".locks", `${major}-${exact}.lock`), async () => {
37
+ if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
38
+ log.info(`cache hit (post-lock): ${electronAppPath}`);
39
+ return;
40
+ }
41
+ await downloadAndExtract({
42
+ version: exact,
43
+ versionDir,
44
+ log,
45
+ downloadBase: opts.downloadBase
46
+ });
47
+ });
48
+ if (!fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) throw new Error(`electron cache install failed: ${electronAppPath} missing after extract`);
49
+ return {
50
+ electronAppPath,
51
+ version: exact,
52
+ major
53
+ };
54
+ }
55
+ async function downloadAndExtract(opts) {
56
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
57
+ const filename = `electron-v${opts.version}-darwin-${arch}.zip`;
58
+ const url = (opts.downloadBase ?? "https://github.com/electron/electron/releases/download") + `/v${opts.version}/${filename}`;
59
+ const zipPath = path.join(ROOT, ".downloads", filename);
60
+ opts.log.info(`downloading ${url}`);
61
+ await downloadFile(url, zipPath, opts.log);
62
+ await fsp.mkdir(opts.versionDir, { recursive: true });
63
+ opts.log.info(`extracting -> ${opts.versionDir}`);
64
+ const r = spawnSync("ditto", [
65
+ "-x",
66
+ "-k",
67
+ zipPath,
68
+ opts.versionDir
69
+ ], {
70
+ stdio: "pipe",
71
+ encoding: "utf8"
72
+ });
73
+ if (r.status !== 0) throw new Error(`ditto failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
74
+ await fsp.unlink(zipPath).catch(() => {});
75
+ }
76
+ async function downloadFile(url, dest, log, redirectsLeft = 5) {
77
+ const tmp = `${dest}.part`;
78
+ if (await new Promise((resolve, reject) => {
79
+ https.get(url, (res) => {
80
+ const code = res.statusCode ?? 0;
81
+ if (code >= 300 && code < 400 && res.headers.location) {
82
+ res.resume();
83
+ if (redirectsLeft <= 0) {
84
+ reject(/* @__PURE__ */ new Error(`download ${url}: too many redirects`));
85
+ return;
86
+ }
87
+ downloadFile(res.headers.location, dest, log, redirectsLeft - 1).then(() => resolve(false), reject);
88
+ return;
89
+ }
90
+ if (code !== 200) {
91
+ res.resume();
92
+ reject(/* @__PURE__ */ new Error(`download ${url} -> HTTP ${code}`));
93
+ return;
94
+ }
95
+ const total = parseInt(res.headers["content-length"] ?? "0", 10);
96
+ let received = 0;
97
+ let lastPct = -1;
98
+ const file = fs.createWriteStream(tmp);
99
+ res.on("data", (chunk) => {
100
+ received += chunk.length;
101
+ if (total > 0) {
102
+ const pct = Math.floor(received / total * 100);
103
+ if (pct !== lastPct && pct % 10 === 0) {
104
+ log.info(` ${pct}% (${received}/${total})`);
105
+ lastPct = pct;
106
+ }
107
+ }
108
+ });
109
+ res.pipe(file);
110
+ file.on("finish", () => {
111
+ file.close((err) => err ? reject(err) : resolve(true));
112
+ });
113
+ file.on("error", reject);
114
+ }).on("error", reject);
115
+ })) await fsp.rename(tmp, dest);
116
+ }
117
+ async function withLock(lockFile, fn) {
118
+ const deadline = Date.now() + 5 * 6e4;
119
+ while (Date.now() < deadline) try {
120
+ const fd = fs.openSync(lockFile, "wx");
121
+ try {
122
+ return await fn();
123
+ } finally {
124
+ fs.closeSync(fd);
125
+ try {
126
+ fs.unlinkSync(lockFile);
127
+ } catch {}
128
+ }
129
+ } catch (err) {
130
+ if (err.code !== "EEXIST") throw err;
131
+ try {
132
+ const stat = fs.statSync(lockFile);
133
+ if (Date.now() - stat.mtimeMs > 10 * 6e4) {
134
+ fs.unlinkSync(lockFile);
135
+ continue;
136
+ }
137
+ } catch {}
138
+ await sleep(500);
139
+ }
140
+ throw new Error(`timed out waiting for ${lockFile}`);
141
+ }
142
+ function sleep(ms) {
143
+ return new Promise((r) => setTimeout(r, ms));
144
+ }
145
+ let registryCache = null;
146
+ async function fetchRegistry() {
147
+ if (registryCache) return registryCache;
148
+ registryCache = new Promise((resolve, reject) => {
149
+ https.get("https://registry.npmjs.org/electron", { headers: { accept: "application/vnd.npm.install-v1+json" } }, (res) => {
150
+ if (res.statusCode !== 200) {
151
+ reject(/* @__PURE__ */ new Error(`registry HTTP ${res.statusCode}`));
152
+ return;
153
+ }
154
+ let body = "";
155
+ res.setEncoding("utf8");
156
+ res.on("data", (c) => body += c);
157
+ res.on("end", () => {
158
+ try {
159
+ resolve(JSON.parse(body));
160
+ } catch (err) {
161
+ reject(err);
162
+ }
163
+ });
164
+ res.on("error", reject);
165
+ }).on("error", reject);
166
+ });
167
+ return registryCache;
168
+ }
169
+ /**
170
+ * Accepts an exact version, a caret range, or a bare major. Returns the
171
+ * latest published electron version that satisfies it. Pure HTTP — no npm
172
+ * CLI invocation.
173
+ */
174
+ async function resolveExactVersion(range, log) {
175
+ const trimmed = range.trim();
176
+ if (/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(trimmed)) return trimmed;
177
+ log.info(`resolving electron ${trimmed} from registry`);
178
+ const doc = await fetchRegistry();
179
+ const all = Object.keys(doc.versions ?? {});
180
+ if (/^\d+$/.test(trimmed)) return pickLatestStableMajor(all, parseInt(trimmed, 10), trimmed);
181
+ const caret = trimmed.match(/^\^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
182
+ if (caret) return pickLatestStableMajor(all, parseInt(caret[1], 10), trimmed);
183
+ const tilde = trimmed.match(/^~(\d+)\.(\d+)(?:\.(\d+))?$/);
184
+ if (tilde) {
185
+ const major = parseInt(tilde[1], 10);
186
+ const minor = parseInt(tilde[2], 10);
187
+ return pickHighest(all.filter((v) => {
188
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/);
189
+ if (!m) return false;
190
+ return parseInt(m[1], 10) === major && parseInt(m[2], 10) === minor;
191
+ }), trimmed);
192
+ }
193
+ if (trimmed === "latest") {
194
+ const latest = doc["dist-tags"]?.latest;
195
+ if (!latest) throw new Error("registry has no `latest` dist-tag");
196
+ return latest;
197
+ }
198
+ throw new Error(`unsupported electron version spec: "${trimmed}". Use an exact version, "^MAJOR", "~MAJOR.MINOR", a bare major, or "latest".`);
199
+ }
200
+ function pickLatestStableMajor(all, major, raw) {
201
+ return pickHighest(all.filter((v) => {
202
+ const m = v.match(/^(\d+)\.\d+\.\d+$/);
203
+ return m !== null && parseInt(m[1], 10) === major;
204
+ }), raw);
205
+ }
206
+ function pickHighest(versions, raw) {
207
+ if (versions.length === 0) throw new Error(`no electron version matched "${raw}"`);
208
+ const sorted = versions.sort(compareSemver);
209
+ return sorted[sorted.length - 1];
210
+ }
211
+ function compareSemver(a, b) {
212
+ const pa = a.split(".").map((n) => parseInt(n, 10));
213
+ const pb = b.split(".").map((n) => parseInt(n, 10));
214
+ for (let i = 0; i < 3; i++) {
215
+ const da = pa[i] ?? 0;
216
+ const db = pb[i] ?? 0;
217
+ if (da !== db) return da - db;
218
+ }
219
+ return 0;
220
+ }
221
+ //#endregion
222
+ //#region src/desktop/icon.ts
223
+ const ICONSET_SIZES = [
224
+ {
225
+ name: "icon_16x16.png",
226
+ px: 16
227
+ },
228
+ {
229
+ name: "icon_16x16@2x.png",
230
+ px: 32
231
+ },
232
+ {
233
+ name: "icon_32x32.png",
234
+ px: 32
235
+ },
236
+ {
237
+ name: "icon_32x32@2x.png",
238
+ px: 64
239
+ },
240
+ {
241
+ name: "icon_128x128.png",
242
+ px: 128
243
+ },
244
+ {
245
+ name: "icon_128x128@2x.png",
246
+ px: 256
247
+ },
248
+ {
249
+ name: "icon_256x256.png",
250
+ px: 256
251
+ },
252
+ {
253
+ name: "icon_256x256@2x.png",
254
+ px: 512
255
+ },
256
+ {
257
+ name: "icon_512x512.png",
258
+ px: 512
259
+ },
260
+ {
261
+ name: "icon_512x512@2x.png",
262
+ px: 1024
263
+ }
264
+ ];
265
+ async function prepareIcon(opts) {
266
+ const { source, dest, log } = opts;
267
+ if (!fs.existsSync(source)) throw new Error(`icon source does not exist: ${source}`);
268
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
269
+ const ext = path.extname(source).toLowerCase();
270
+ if (ext === ".icns") {
271
+ log.info(`copying icns ${source} -> ${dest}`);
272
+ await fsp.copyFile(source, dest);
273
+ return;
274
+ }
275
+ if (ext !== ".png") throw new Error(`unsupported icon format "${ext}". Use .png (square, ideally 1024x1024) or .icns.`);
276
+ log.info(`converting png -> icns (${source})`);
277
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "czda-iconset-"));
278
+ const iconset = path.join(tmp, "icon.iconset");
279
+ await fsp.mkdir(iconset, { recursive: true });
280
+ for (const { name, px } of ICONSET_SIZES) {
281
+ const out = path.join(iconset, name);
282
+ const r = spawnSync("sips", [
283
+ "-z",
284
+ String(px),
285
+ String(px),
286
+ source,
287
+ "--out",
288
+ out
289
+ ], {
290
+ stdio: "pipe",
291
+ encoding: "utf8"
292
+ });
293
+ if (r.status !== 0) throw new Error(`sips failed for ${name} (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
294
+ }
295
+ const r = spawnSync("iconutil", [
296
+ "-c",
297
+ "icns",
298
+ iconset,
299
+ "-o",
300
+ dest
301
+ ], {
302
+ stdio: "pipe",
303
+ encoding: "utf8"
304
+ });
305
+ if (r.status !== 0) throw new Error(`iconutil failed (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
306
+ await fsp.rm(tmp, {
307
+ recursive: true,
308
+ force: true
309
+ });
310
+ }
311
+ //#endregion
312
+ //#region src/desktop/bundle.ts
313
+ const ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
314
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
315
+ <plist version="1.0">
316
+ <dict>
317
+ <key>com.apple.security.cs.allow-jit</key>
318
+ <true/>
319
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
320
+ <true/>
321
+ <key>com.apple.security.cs.disable-library-validation</key>
322
+ <true/>
323
+ </dict>
324
+ </plist>
325
+ `;
326
+ async function synthesizeBundle(opts) {
327
+ const { cachedElectronApp, destApp, displayName, slug, version, bundleId = `dev.zenbu.${slug}`, iconSource, packageManager, appsDir, launcherSource, log, dryRun = false, force = false } = opts;
328
+ if (!fs.existsSync(cachedElectronApp)) throw new Error(`cached Electron.app missing: ${cachedElectronApp}. Did the cache step fail?`);
329
+ if (fs.existsSync(destApp)) {
330
+ if (!force) throw new Error(`${destApp} already exists. Re-run with --force to overwrite.`);
331
+ if (dryRun) log.info(`[dry-run] would rm -rf ${destApp}`);
332
+ else {
333
+ log.info(`removing existing ${destApp}`);
334
+ await fsp.rm(destApp, {
335
+ recursive: true,
336
+ force: true
337
+ });
338
+ }
339
+ }
340
+ await ensureParentDir(destApp, dryRun, log);
341
+ await log.withStep(`clone-copy ${cachedElectronApp} -> ${destApp}`, async () => {
342
+ if (dryRun) return;
343
+ cloneCopy(cachedElectronApp, destApp, log);
344
+ });
345
+ const macosDir = path.join(destApp, "Contents", "MacOS");
346
+ const oldExe = path.join(macosDir, "Electron");
347
+ const newExe = path.join(macosDir, displayName);
348
+ await log.withStep(`rename executable -> ${displayName}`, async () => {
349
+ if (dryRun) return;
350
+ if (fs.existsSync(oldExe)) await fsp.rename(oldExe, newExe);
351
+ else if (!fs.existsSync(newExe)) throw new Error(`expected ${oldExe} or ${newExe} to exist after clone-copy`);
352
+ });
353
+ const infoPlist = path.join(destApp, "Contents", "Info.plist");
354
+ await log.withStep(`patch Info.plist`, async () => {
355
+ if (dryRun) return;
356
+ const ops = [
357
+ {
358
+ key: "CFBundleName",
359
+ type: "string",
360
+ value: displayName
361
+ },
362
+ {
363
+ key: "CFBundleDisplayName",
364
+ type: "string",
365
+ value: displayName
366
+ },
367
+ {
368
+ key: "CFBundleExecutable",
369
+ type: "string",
370
+ value: displayName
371
+ },
372
+ {
373
+ key: "CFBundleIdentifier",
374
+ type: "string",
375
+ value: bundleId
376
+ },
377
+ {
378
+ key: "CFBundleIconFile",
379
+ type: "string",
380
+ value: "icon.icns"
381
+ },
382
+ {
383
+ key: "CFBundleVersion",
384
+ type: "string",
385
+ value: version
386
+ },
387
+ {
388
+ key: "CFBundleShortVersionString",
389
+ type: "string",
390
+ value: version
391
+ },
392
+ {
393
+ key: "NSHighResolutionCapable",
394
+ type: "bool",
395
+ value: true
396
+ },
397
+ {
398
+ key: "LSMinimumSystemVersion",
399
+ type: "string",
400
+ value: "12.0"
401
+ },
402
+ {
403
+ key: "NSSupportsAutomaticGraphicsSwitching",
404
+ type: "bool",
405
+ value: true
406
+ },
407
+ {
408
+ key: "NSRequiresAquaSystemAppearance",
409
+ type: "bool",
410
+ value: false
411
+ },
412
+ {
413
+ key: "NSPrincipalClass",
414
+ type: "string",
415
+ value: "AtomApplication"
416
+ }
417
+ ];
418
+ for (const op of ops) plutilReplace(infoPlist, op, log);
419
+ plutilRemove(infoPlist, ":ElectronAsarIntegrity", log);
420
+ });
421
+ const resourcesDir = path.join(destApp, "Contents", "Resources");
422
+ const iconDest = path.join(resourcesDir, "icon.icns");
423
+ await log.withStep(`install icon -> ${iconDest}`, async () => {
424
+ if (dryRun) return;
425
+ if (iconSource) await prepareIcon({
426
+ source: iconSource,
427
+ dest: iconDest,
428
+ log
429
+ });
430
+ else {
431
+ const fallback = path.join(resourcesDir, "electron.icns");
432
+ if (fs.existsSync(fallback)) await fsp.copyFile(fallback, iconDest);
433
+ else log.info(`no icon source and no electron.icns fallback; skipping`);
434
+ }
435
+ });
436
+ const bundleAppDir = path.join(resourcesDir, "app");
437
+ await log.withStep(`write Resources/app/{package.json,launcher.mjs,app-config.json,host.json}`, async () => {
438
+ if (dryRun) return;
439
+ await fsp.mkdir(bundleAppDir, { recursive: true });
440
+ const bundlePkg = {
441
+ name: slug,
442
+ version,
443
+ main: "launcher.mjs",
444
+ type: "module"
445
+ };
446
+ await fsp.writeFile(path.join(bundleAppDir, "package.json"), JSON.stringify(bundlePkg, null, 2) + "\n");
447
+ if (!fs.existsSync(launcherSource)) throw new Error(`launcher source missing: ${launcherSource}`);
448
+ await fsp.copyFile(launcherSource, path.join(bundleAppDir, "launcher.mjs"));
449
+ const appConfig = {
450
+ name: slug,
451
+ packageManager,
452
+ version,
453
+ local: true
454
+ };
455
+ await fsp.writeFile(path.join(bundleAppDir, "app-config.json"), JSON.stringify(appConfig, null, 2) + "\n");
456
+ await fsp.writeFile(path.join(bundleAppDir, "host.json"), JSON.stringify({ version }, null, 2) + "\n");
457
+ log.info(`appsDir baked into name = ${slug} (resolves to ${appsDir})`);
458
+ });
459
+ await adhocCodesign({
460
+ destApp,
461
+ log,
462
+ dryRun
463
+ });
464
+ await stripQuarantine({
465
+ destApp,
466
+ log,
467
+ dryRun
468
+ });
469
+ await log.withStep(`verify (codesign + plutil)`, async () => {
470
+ if (dryRun) return;
471
+ const cs = spawnSync("codesign", [
472
+ "--verify",
473
+ "--deep",
474
+ "--strict",
475
+ destApp
476
+ ], {
477
+ stdio: "pipe",
478
+ encoding: "utf8"
479
+ });
480
+ if (cs.status !== 0) throw new Error(`codesign verify failed (${cs.status}): ${cs.stderr?.slice(0, 1e3) ?? ""}`);
481
+ const pl = spawnSync("plutil", ["-lint", infoPlist], {
482
+ stdio: "pipe",
483
+ encoding: "utf8"
484
+ });
485
+ if (pl.status !== 0) throw new Error(`plutil -lint failed (${pl.status}): ${pl.stderr?.slice(0, 500) ?? ""} ${pl.stdout?.slice(0, 500) ?? ""}`);
486
+ });
487
+ }
488
+ /**
489
+ * Ad-hoc deep-sign a bundle with the same entitlements used at synthesize
490
+ * time. Reusable so post-synthesize mutations (e.g. icon swap) can re-sign
491
+ * without rebuilding the whole bundle.
492
+ */
493
+ async function adhocCodesign(opts) {
494
+ const { destApp, log, dryRun = false } = opts;
495
+ const entitlementsPath = path.join(os.tmpdir(), `czda-entitlements-${process.pid}-${Date.now()}.plist`);
496
+ await log.withStep(`ad-hoc codesign --deep`, async () => {
497
+ if (dryRun) return;
498
+ await fsp.writeFile(entitlementsPath, ENTITLEMENTS);
499
+ try {
500
+ const r = spawnSync("codesign", [
501
+ "--force",
502
+ "--deep",
503
+ "--sign",
504
+ "-",
505
+ "--entitlements",
506
+ entitlementsPath,
507
+ destApp
508
+ ], {
509
+ stdio: "pipe",
510
+ encoding: "utf8"
511
+ });
512
+ if (r.status !== 0) throw new Error(`codesign failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
513
+ log.info(r.stderr?.trim() || "codesign ok");
514
+ } finally {
515
+ await fsp.unlink(entitlementsPath).catch(() => {});
516
+ }
517
+ });
518
+ }
519
+ async function stripQuarantine(opts) {
520
+ const { destApp, log, dryRun = false } = opts;
521
+ await log.withStep(`xattr -dr com.apple.quarantine`, async () => {
522
+ if (dryRun) return;
523
+ spawnSync("xattr", [
524
+ "-dr",
525
+ "com.apple.quarantine",
526
+ destApp
527
+ ], { stdio: "ignore" });
528
+ });
529
+ }
530
+ function cloneCopy(src, dest, log) {
531
+ const r = spawnSync("cp", [
532
+ "-c",
533
+ "-R",
534
+ src,
535
+ dest
536
+ ], {
537
+ stdio: "pipe",
538
+ encoding: "utf8"
539
+ });
540
+ if (r.status === 0) return;
541
+ log.info(`cp -c failed, falling back to cp -R: ${r.stderr?.trim() ?? ""}`);
542
+ const r2 = spawnSync("cp", [
543
+ "-R",
544
+ src,
545
+ dest
546
+ ], {
547
+ stdio: "pipe",
548
+ encoding: "utf8"
549
+ });
550
+ if (r2.status !== 0) throw new Error(`cp failed (${r2.status}): ${r2.stderr?.slice(0, 1e3) ?? ""}`);
551
+ }
552
+ async function ensureParentDir(destApp, dryRun, log) {
553
+ const parent = path.dirname(destApp);
554
+ if (fs.existsSync(parent)) return;
555
+ if (dryRun) {
556
+ log.info(`[dry-run] would mkdir -p ${parent}`);
557
+ return;
558
+ }
559
+ await fsp.mkdir(parent, { recursive: true });
560
+ }
561
+ function plutilReplace(infoPlist, op, log) {
562
+ const value = String(op.value);
563
+ if (spawnSync("plutil", [
564
+ "-replace",
565
+ op.key,
566
+ `-${op.type}`,
567
+ value,
568
+ infoPlist
569
+ ], {
570
+ stdio: "pipe",
571
+ encoding: "utf8"
572
+ }).status === 0) return;
573
+ const insert = spawnSync("plutil", [
574
+ "-insert",
575
+ op.key,
576
+ `-${op.type}`,
577
+ value,
578
+ infoPlist
579
+ ], {
580
+ stdio: "pipe",
581
+ encoding: "utf8"
582
+ });
583
+ if (insert.status !== 0) throw new Error(`plutil -insert ${op.key} failed (${insert.status}): ${insert.stderr?.slice(0, 500) ?? ""}`);
584
+ log.info(`plutil inserted ${op.key}=${value}`);
585
+ }
586
+ function plutilRemove(infoPlist, keyPath, log) {
587
+ const r = spawnSync("plutil", [
588
+ "-remove",
589
+ keyPath,
590
+ infoPlist
591
+ ], {
592
+ stdio: "pipe",
593
+ encoding: "utf8"
594
+ });
595
+ if (r.status === 0) {
596
+ log.info(`plutil removed ${keyPath}`);
597
+ return;
598
+ }
599
+ log.info(`plutil -remove ${keyPath}: ${r.stderr?.trim() ?? "(no key)"}`);
600
+ }
601
+ /**
602
+ * Resolve the path to `@zenbujs/core/dist/launcher.mjs` from any cwd. We
603
+ * use `package.json` as the resolution anchor because that subpath is
604
+ * exported (see packages/core/package.json `exports`).
605
+ */
606
+ function resolveLauncherSource(fromDir) {
607
+ const req = createRequire(path.join(fromDir, "noop.js"));
608
+ let pkgPath;
609
+ try {
610
+ pkgPath = req.resolve("@zenbujs/core/package.json");
611
+ } catch (err) {
612
+ throw new Error(`failed to resolve @zenbujs/core from ${fromDir}: ${err.message}. Make sure create-zenbu-app's dependency on @zenbujs/core is installed.`);
613
+ }
614
+ const launcher = path.join(path.dirname(pkgPath), "dist", "launcher.mjs");
615
+ if (!fs.existsSync(launcher)) throw new Error(`@zenbujs/core launcher not found at ${launcher}. The package may be a stub or unbuilt.`);
616
+ return launcher;
617
+ }
618
+ //#endregion
619
+ //#region src/desktop/deps-sig.ts
620
+ function lockfileFor(type) {
621
+ switch (type) {
622
+ case "pnpm": return "pnpm-lock.yaml";
623
+ case "npm": return "package-lock.json";
624
+ case "yarn": return "yarn.lock";
625
+ case "bun": return "bun.lock";
626
+ }
627
+ }
628
+ /**
629
+ * Mirror of `packages/core/src/shared/pm-install.ts#depsSignature`, with
630
+ * `electronVersion` accepted explicitly because we run from Node (not
631
+ * Electron) and `process.versions.electron` is undefined here.
632
+ *
633
+ * Must stay byte-for-byte equivalent to the launcher's recipe so the
634
+ * pre-seeded `<appsDir>/.zenbu/deps-sig` matches what `ensureDepsInstalled`
635
+ * recomputes on first launch and the install gate skips.
636
+ */
637
+ async function depsSignature(opts) {
638
+ const hash = crypto.createHash("sha256");
639
+ await fileHash(hash, path.join(opts.appsDir, "package.json"));
640
+ await fileHash(hash, path.join(opts.appsDir, lockfileFor(opts.pm.type)));
641
+ hash.update(`${opts.pm.type}@${opts.pm.version}`);
642
+ hash.update("\0");
643
+ hash.update(opts.electronVersion);
644
+ hash.update("\0");
645
+ hash.update(opts.platform ?? process.platform);
646
+ hash.update("\0");
647
+ hash.update(opts.arch ?? process.arch);
648
+ return hash.digest("hex");
649
+ }
650
+ async function fileHash(hash, filePath) {
651
+ hash.update(filePath);
652
+ hash.update("\0");
653
+ try {
654
+ hash.update(await fsp.readFile(filePath));
655
+ } catch {}
656
+ hash.update("\0");
657
+ }
658
+ async function writeDepsSig(appsDir, sig) {
659
+ const sigPath = path.join(appsDir, ".zenbu", "deps-sig");
660
+ await fsp.mkdir(path.dirname(sigPath), { recursive: true });
661
+ await fsp.writeFile(sigPath, sig);
662
+ }
663
+ //#endregion
664
+ //#region src/desktop/log.ts
665
+ const LOG_DIR = path.join(os.homedir(), ".zenbu", "logs", "create-zenbu-app");
666
+ function ts() {
667
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
668
+ }
669
+ function createLogger(opts) {
670
+ fs.mkdirSync(LOG_DIR, { recursive: true });
671
+ const file = path.join(LOG_DIR, `${ts()}-${opts.slug}.log`);
672
+ const stream = fs.createWriteStream(file, { flags: "a" });
673
+ stream.write(`=== create-zenbu-app --desktop ${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} slug=${opts.slug} ===\n`);
674
+ const writeLine = (prefix, line) => {
675
+ const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${prefix}${line}\n`;
676
+ try {
677
+ stream.write(text);
678
+ } catch {}
679
+ if (opts.verbose) process.stdout.write(text);
680
+ };
681
+ return {
682
+ file,
683
+ step(label) {
684
+ writeLine("[STEP] ", label);
685
+ },
686
+ info(line) {
687
+ writeLine("", line);
688
+ },
689
+ error(line) {
690
+ const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] [ERR] ${line}\n`;
691
+ try {
692
+ stream.write(text);
693
+ } catch {}
694
+ process.stderr.write(text);
695
+ },
696
+ async withStep(label, fn) {
697
+ this.step(label);
698
+ const start = Date.now();
699
+ try {
700
+ const out = await fn();
701
+ writeLine("[STEP-OK] ", `${label} (${Date.now() - start}ms)`);
702
+ return out;
703
+ } catch (err) {
704
+ const e = err;
705
+ writeLine("[STEP-FAIL] ", `${label} (${Date.now() - start}ms): ${e.stack ?? e.message ?? String(err)}`);
706
+ throw err;
707
+ }
708
+ },
709
+ tail(n = 30) {
710
+ try {
711
+ const all = fs.readFileSync(file, "utf8").split("\n");
712
+ return all.slice(Math.max(0, all.length - n - 1)).join("\n");
713
+ } catch {
714
+ return "";
715
+ }
716
+ },
717
+ close() {
718
+ try {
719
+ stream.end();
720
+ } catch {}
721
+ }
722
+ };
723
+ }
724
+ //#endregion
725
+ //#region src/desktop/update-icon.ts
726
+ /**
727
+ * Swap an existing bundle's `Contents/Resources/icon.icns`, then re-codesign
728
+ * (ad-hoc, deep) so the bundle stays valid. Optionally nudges Launch
729
+ * Services to re-read the icon.
730
+ */
731
+ async function updateBundleIcon(opts) {
732
+ const { destApp, iconSource, log, refreshIconServices = true } = opts;
733
+ if (!fs.existsSync(destApp)) throw new Error(`bundle does not exist: ${destApp}`);
734
+ if (!destApp.endsWith(".app")) throw new Error(`expected an .app bundle, got: ${destApp}`);
735
+ if (!fs.existsSync(iconSource)) throw new Error(`icon source does not exist: ${iconSource}`);
736
+ const iconDest = path.join(destApp, "Contents", "Resources", "icon.icns");
737
+ await log.withStep(`replace icon -> ${iconDest}`, async () => {
738
+ await prepareIcon({
739
+ source: iconSource,
740
+ dest: iconDest,
741
+ log
742
+ });
743
+ });
744
+ await adhocCodesign({
745
+ destApp,
746
+ log
747
+ });
748
+ await stripQuarantine({
749
+ destApp,
750
+ log
751
+ });
752
+ if (refreshIconServices) await log.withStep(`refresh Launch Services / Dock`, async () => {
753
+ const now = /* @__PURE__ */ new Date();
754
+ try {
755
+ await fsp.utimes(destApp, now, now);
756
+ } catch (err) {
757
+ log.info(`utimes failed: ${err.message}`);
758
+ }
759
+ const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
760
+ if (fs.existsSync(lsregister)) {
761
+ const r = spawnSync(lsregister, ["-f", destApp], {
762
+ stdio: "pipe",
763
+ encoding: "utf8"
764
+ });
765
+ if (r.status !== 0) log.info(`lsregister exit=${r.status} ${r.stderr?.trim() ?? ""}`);
766
+ }
767
+ const iconSvcCache = path.join(os.homedir(), "Library", "Caches", "com.apple.iconservices.store");
768
+ try {
769
+ if (fs.existsSync(iconSvcCache)) await fsp.rm(iconSvcCache, { force: true });
770
+ } catch {}
771
+ spawnSync("killall", ["Dock"], { stdio: "ignore" });
772
+ spawnSync("killall", ["Finder"], { stdio: "ignore" });
773
+ });
774
+ }
775
+ //#endregion
776
+ //#region src/desktop/index.ts
777
+ /**
778
+ * Default destination when `destApp` not provided. We prefer `/Applications`
779
+ * but fall back to `~/Applications` when /Applications isn't writable
780
+ * (e.g. without admin rights or on locked-down setups).
781
+ */
782
+ function defaultDestApp(displayName) {
783
+ const sysApps = "/Applications";
784
+ try {
785
+ fs.accessSync(sysApps, fs.constants.W_OK);
786
+ return path.join(sysApps, `${displayName}.app`);
787
+ } catch {
788
+ const userApps = path.join(os.homedir(), "Applications");
789
+ return path.join(userApps, `${displayName}.app`);
790
+ }
791
+ }
792
+ async function buildDesktopApp(opts) {
793
+ const { displayName, slug, version, electronVersionRange, iconSource, appsDir, packageManager, resolveFrom, log, force = false, dryRun = false, skipDepsSig = false } = opts;
794
+ const destApp = opts.destApp ?? defaultDestApp(displayName);
795
+ const versionRange = electronVersionRange ?? await readElectronRangeFromAppsDir(appsDir) ?? "latest";
796
+ log.info(`displayName=${displayName} slug=${slug} version=${version}`);
797
+ log.info(`appsDir=${appsDir}`);
798
+ log.info(`destApp=${destApp}`);
799
+ log.info(`electronVersionRange=${versionRange}`);
800
+ const cache = await log.withStep(`ensure electron cache (${versionRange})`, () => ensureElectronApp({
801
+ versionRange,
802
+ log
803
+ }));
804
+ const launcherSource = resolveLauncherSource(resolveFrom);
805
+ log.info(`launcher source: ${launcherSource}`);
806
+ await synthesizeBundle({
807
+ cachedElectronApp: cache.electronAppPath,
808
+ destApp,
809
+ displayName,
810
+ slug,
811
+ version,
812
+ iconSource,
813
+ packageManager,
814
+ appsDir,
815
+ launcherSource,
816
+ log,
817
+ dryRun,
818
+ force
819
+ });
820
+ if (!skipDepsSig && !dryRun) await log.withStep(`write <appsDir>/.zenbu/deps-sig`, async () => {
821
+ const sig = await depsSignature({
822
+ appsDir,
823
+ pm: packageManager,
824
+ electronVersion: cache.version
825
+ });
826
+ await writeDepsSig(appsDir, sig);
827
+ log.info(`deps-sig=${sig.slice(0, 16)}…`);
828
+ });
829
+ else if (skipDepsSig) log.info(`skipping deps-sig (--no-install or skipDepsSig=true)`);
830
+ return {
831
+ destApp,
832
+ electronVersion: cache.version,
833
+ appsDir,
834
+ launchCommand: `open -a "${destApp}"`
835
+ };
836
+ }
837
+ async function readElectronRangeFromAppsDir(appsDir) {
838
+ try {
839
+ const raw = await fsp.readFile(path.join(appsDir, "package.json"), "utf8");
840
+ const pkg = JSON.parse(raw);
841
+ return pkg.devDependencies?.electron ?? pkg.dependencies?.electron ?? null;
842
+ } catch {
843
+ return null;
844
+ }
845
+ }
846
+ //#endregion
847
+ export { writeDepsSig as a, stripQuarantine as c, ensureElectronApp as d, depsSignature as i, synthesizeBundle as l, updateBundleIcon as n, adhocCodesign as o, createLogger as r, resolveLauncherSource as s, buildDesktopApp as t, prepareIcon as u };