create-zenbu-app 0.0.22 → 0.0.24

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/dist/index.mjs CHANGED
@@ -1,19 +1,798 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
4
+ import os from "node:os";
3
5
  import fs from "node:fs";
4
6
  import { fileURLToPath } from "node:url";
5
7
  import { spawnSync } from "node:child_process";
6
8
  import * as p from "@clack/prompts";
9
+ import fsp from "node:fs/promises";
10
+ import https from "node:https";
11
+ import crypto from "node:crypto";
12
+ //#region src/desktop/electron-cache.ts
13
+ /**
14
+ * Layout under `~/.zenbu/electron/`:
15
+ *
16
+ * versions/<major>/<exact-version>/Electron.app
17
+ * versions/<major>/.current -> "<exact-version>" (text marker)
18
+ * .locks/<major>.lock -> flock target during download
19
+ * .downloads/<exact>.zip -> intermediate, deleted on success
20
+ */
21
+ const ROOT = path.join(os.homedir(), ".zenbu", "electron");
22
+ async function ensureElectronApp(opts) {
23
+ const { log } = opts;
24
+ const exact = await resolveExactVersion(opts.versionRange, log);
25
+ const major = parseInt(exact.split(".")[0], 10);
26
+ if (!Number.isFinite(major)) throw new Error(`failed to parse major from electron version "${exact}"`);
27
+ const versionDir = path.join(ROOT, "versions", String(major), exact);
28
+ const electronAppPath = path.join(versionDir, "Electron.app");
29
+ if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
30
+ log.info(`cache hit: ${electronAppPath}`);
31
+ return {
32
+ electronAppPath,
33
+ version: exact,
34
+ major
35
+ };
36
+ }
37
+ await fsp.mkdir(path.join(ROOT, ".locks"), { recursive: true });
38
+ await fsp.mkdir(path.join(ROOT, ".downloads"), { recursive: true });
39
+ await withLock(path.join(ROOT, ".locks", `${major}-${exact}.lock`), async () => {
40
+ if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
41
+ log.info(`cache hit (post-lock): ${electronAppPath}`);
42
+ return;
43
+ }
44
+ await downloadAndExtract({
45
+ version: exact,
46
+ versionDir,
47
+ log,
48
+ downloadBase: opts.downloadBase
49
+ });
50
+ });
51
+ if (!fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) throw new Error(`electron cache install failed: ${electronAppPath} missing after extract`);
52
+ return {
53
+ electronAppPath,
54
+ version: exact,
55
+ major
56
+ };
57
+ }
58
+ async function downloadAndExtract(opts) {
59
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
60
+ const filename = `electron-v${opts.version}-darwin-${arch}.zip`;
61
+ const url = (opts.downloadBase ?? "https://github.com/electron/electron/releases/download") + `/v${opts.version}/${filename}`;
62
+ const zipPath = path.join(ROOT, ".downloads", filename);
63
+ opts.log.info(`downloading ${url}`);
64
+ await downloadFile(url, zipPath, opts.log);
65
+ await fsp.mkdir(opts.versionDir, { recursive: true });
66
+ opts.log.info(`extracting -> ${opts.versionDir}`);
67
+ const r = spawnSync("ditto", [
68
+ "-x",
69
+ "-k",
70
+ zipPath,
71
+ opts.versionDir
72
+ ], {
73
+ stdio: "pipe",
74
+ encoding: "utf8"
75
+ });
76
+ if (r.status !== 0) throw new Error(`ditto failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
77
+ await fsp.unlink(zipPath).catch(() => {});
78
+ }
79
+ async function downloadFile(url, dest, log, redirectsLeft = 5) {
80
+ const tmp = `${dest}.part`;
81
+ if (await new Promise((resolve, reject) => {
82
+ https.get(url, (res) => {
83
+ const code = res.statusCode ?? 0;
84
+ if (code >= 300 && code < 400 && res.headers.location) {
85
+ res.resume();
86
+ if (redirectsLeft <= 0) {
87
+ reject(/* @__PURE__ */ new Error(`download ${url}: too many redirects`));
88
+ return;
89
+ }
90
+ downloadFile(res.headers.location, dest, log, redirectsLeft - 1).then(() => resolve(false), reject);
91
+ return;
92
+ }
93
+ if (code !== 200) {
94
+ res.resume();
95
+ reject(/* @__PURE__ */ new Error(`download ${url} -> HTTP ${code}`));
96
+ return;
97
+ }
98
+ const total = parseInt(res.headers["content-length"] ?? "0", 10);
99
+ let received = 0;
100
+ let lastPct = -1;
101
+ const file = fs.createWriteStream(tmp);
102
+ res.on("data", (chunk) => {
103
+ received += chunk.length;
104
+ if (total > 0) {
105
+ const pct = Math.floor(received / total * 100);
106
+ if (pct !== lastPct && pct % 10 === 0) {
107
+ log.info(` ${pct}% (${received}/${total})`);
108
+ lastPct = pct;
109
+ }
110
+ }
111
+ });
112
+ res.pipe(file);
113
+ file.on("finish", () => {
114
+ file.close((err) => err ? reject(err) : resolve(true));
115
+ });
116
+ file.on("error", reject);
117
+ }).on("error", reject);
118
+ })) await fsp.rename(tmp, dest);
119
+ }
120
+ async function withLock(lockFile, fn) {
121
+ const deadline = Date.now() + 5 * 6e4;
122
+ while (Date.now() < deadline) try {
123
+ const fd = fs.openSync(lockFile, "wx");
124
+ try {
125
+ return await fn();
126
+ } finally {
127
+ fs.closeSync(fd);
128
+ try {
129
+ fs.unlinkSync(lockFile);
130
+ } catch {}
131
+ }
132
+ } catch (err) {
133
+ if (err.code !== "EEXIST") throw err;
134
+ try {
135
+ const stat = fs.statSync(lockFile);
136
+ if (Date.now() - stat.mtimeMs > 10 * 6e4) {
137
+ fs.unlinkSync(lockFile);
138
+ continue;
139
+ }
140
+ } catch {}
141
+ await sleep(500);
142
+ }
143
+ throw new Error(`timed out waiting for ${lockFile}`);
144
+ }
145
+ function sleep(ms) {
146
+ return new Promise((r) => setTimeout(r, ms));
147
+ }
148
+ let registryCache = null;
149
+ async function fetchRegistry() {
150
+ if (registryCache) return registryCache;
151
+ registryCache = new Promise((resolve, reject) => {
152
+ https.get("https://registry.npmjs.org/electron", { headers: { accept: "application/vnd.npm.install-v1+json" } }, (res) => {
153
+ if (res.statusCode !== 200) {
154
+ reject(/* @__PURE__ */ new Error(`registry HTTP ${res.statusCode}`));
155
+ return;
156
+ }
157
+ let body = "";
158
+ res.setEncoding("utf8");
159
+ res.on("data", (c) => body += c);
160
+ res.on("end", () => {
161
+ try {
162
+ resolve(JSON.parse(body));
163
+ } catch (err) {
164
+ reject(err);
165
+ }
166
+ });
167
+ res.on("error", reject);
168
+ }).on("error", reject);
169
+ });
170
+ return registryCache;
171
+ }
172
+ /**
173
+ * Accepts an exact version, a caret range, or a bare major. Returns the
174
+ * latest published electron version that satisfies it. Pure HTTP — no npm
175
+ * CLI invocation.
176
+ */
177
+ async function resolveExactVersion(range, log) {
178
+ const trimmed = range.trim();
179
+ if (/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(trimmed)) return trimmed;
180
+ log.info(`resolving electron ${trimmed} from registry`);
181
+ const doc = await fetchRegistry();
182
+ const all = Object.keys(doc.versions ?? {});
183
+ if (/^\d+$/.test(trimmed)) return pickLatestStableMajor(all, parseInt(trimmed, 10), trimmed);
184
+ const caret = trimmed.match(/^\^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
185
+ if (caret) return pickLatestStableMajor(all, parseInt(caret[1], 10), trimmed);
186
+ const tilde = trimmed.match(/^~(\d+)\.(\d+)(?:\.(\d+))?$/);
187
+ if (tilde) {
188
+ const major = parseInt(tilde[1], 10);
189
+ const minor = parseInt(tilde[2], 10);
190
+ return pickHighest(all.filter((v) => {
191
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/);
192
+ if (!m) return false;
193
+ return parseInt(m[1], 10) === major && parseInt(m[2], 10) === minor;
194
+ }), trimmed);
195
+ }
196
+ if (trimmed === "latest") {
197
+ const latest = doc["dist-tags"]?.latest;
198
+ if (!latest) throw new Error("registry has no `latest` dist-tag");
199
+ return latest;
200
+ }
201
+ throw new Error(`unsupported electron version spec: "${trimmed}". Use an exact version, "^MAJOR", "~MAJOR.MINOR", a bare major, or "latest".`);
202
+ }
203
+ function pickLatestStableMajor(all, major, raw) {
204
+ return pickHighest(all.filter((v) => {
205
+ const m = v.match(/^(\d+)\.\d+\.\d+$/);
206
+ return m !== null && parseInt(m[1], 10) === major;
207
+ }), raw);
208
+ }
209
+ function pickHighest(versions, raw) {
210
+ if (versions.length === 0) throw new Error(`no electron version matched "${raw}"`);
211
+ const sorted = versions.sort(compareSemver);
212
+ return sorted[sorted.length - 1];
213
+ }
214
+ function compareSemver(a, b) {
215
+ const pa = a.split(".").map((n) => parseInt(n, 10));
216
+ const pb = b.split(".").map((n) => parseInt(n, 10));
217
+ for (let i = 0; i < 3; i++) {
218
+ const da = pa[i] ?? 0;
219
+ const db = pb[i] ?? 0;
220
+ if (da !== db) return da - db;
221
+ }
222
+ return 0;
223
+ }
224
+ //#endregion
225
+ //#region src/desktop/icon.ts
226
+ const ICONSET_SIZES = [
227
+ {
228
+ name: "icon_16x16.png",
229
+ px: 16
230
+ },
231
+ {
232
+ name: "icon_16x16@2x.png",
233
+ px: 32
234
+ },
235
+ {
236
+ name: "icon_32x32.png",
237
+ px: 32
238
+ },
239
+ {
240
+ name: "icon_32x32@2x.png",
241
+ px: 64
242
+ },
243
+ {
244
+ name: "icon_128x128.png",
245
+ px: 128
246
+ },
247
+ {
248
+ name: "icon_128x128@2x.png",
249
+ px: 256
250
+ },
251
+ {
252
+ name: "icon_256x256.png",
253
+ px: 256
254
+ },
255
+ {
256
+ name: "icon_256x256@2x.png",
257
+ px: 512
258
+ },
259
+ {
260
+ name: "icon_512x512.png",
261
+ px: 512
262
+ },
263
+ {
264
+ name: "icon_512x512@2x.png",
265
+ px: 1024
266
+ }
267
+ ];
268
+ async function prepareIcon(opts) {
269
+ const { source, dest, log } = opts;
270
+ if (!fs.existsSync(source)) throw new Error(`icon source does not exist: ${source}`);
271
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
272
+ const ext = path.extname(source).toLowerCase();
273
+ if (ext === ".icns") {
274
+ log.info(`copying icns ${source} -> ${dest}`);
275
+ await fsp.copyFile(source, dest);
276
+ return;
277
+ }
278
+ if (ext !== ".png") throw new Error(`unsupported icon format "${ext}". Use .png (square, ideally 1024x1024) or .icns.`);
279
+ log.info(`converting png -> icns (${source})`);
280
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "czda-iconset-"));
281
+ const iconset = path.join(tmp, "icon.iconset");
282
+ await fsp.mkdir(iconset, { recursive: true });
283
+ for (const { name, px } of ICONSET_SIZES) {
284
+ const out = path.join(iconset, name);
285
+ const r = spawnSync("sips", [
286
+ "-z",
287
+ String(px),
288
+ String(px),
289
+ source,
290
+ "--out",
291
+ out
292
+ ], {
293
+ stdio: "pipe",
294
+ encoding: "utf8"
295
+ });
296
+ if (r.status !== 0) throw new Error(`sips failed for ${name} (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
297
+ }
298
+ const r = spawnSync("iconutil", [
299
+ "-c",
300
+ "icns",
301
+ iconset,
302
+ "-o",
303
+ dest
304
+ ], {
305
+ stdio: "pipe",
306
+ encoding: "utf8"
307
+ });
308
+ if (r.status !== 0) throw new Error(`iconutil failed (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
309
+ await fsp.rm(tmp, {
310
+ recursive: true,
311
+ force: true
312
+ });
313
+ }
314
+ //#endregion
315
+ //#region src/desktop/bundle.ts
316
+ const ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
317
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
318
+ <plist version="1.0">
319
+ <dict>
320
+ <key>com.apple.security.cs.allow-jit</key>
321
+ <true/>
322
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
323
+ <true/>
324
+ <key>com.apple.security.cs.disable-library-validation</key>
325
+ <true/>
326
+ </dict>
327
+ </plist>
328
+ `;
329
+ async function synthesizeBundle(opts) {
330
+ const { cachedElectronApp, destApp, displayName, slug, version, bundleId = `dev.zenbu.${slug}`, iconSource, packageManager, appsDir, launcherSource, log, dryRun = false, force = false } = opts;
331
+ if (!fs.existsSync(cachedElectronApp)) throw new Error(`cached Electron.app missing: ${cachedElectronApp}. Did the cache step fail?`);
332
+ if (fs.existsSync(destApp)) {
333
+ if (!force) throw new Error(`${destApp} already exists. Re-run with --force to overwrite.`);
334
+ if (dryRun) log.info(`[dry-run] would rm -rf ${destApp}`);
335
+ else {
336
+ log.info(`removing existing ${destApp}`);
337
+ await fsp.rm(destApp, {
338
+ recursive: true,
339
+ force: true
340
+ });
341
+ }
342
+ }
343
+ await ensureParentDir(destApp, dryRun, log);
344
+ await log.withStep(`clone-copy ${cachedElectronApp} -> ${destApp}`, async () => {
345
+ if (dryRun) return;
346
+ cloneCopy(cachedElectronApp, destApp, log);
347
+ });
348
+ const macosDir = path.join(destApp, "Contents", "MacOS");
349
+ const oldExe = path.join(macosDir, "Electron");
350
+ const newExe = path.join(macosDir, displayName);
351
+ await log.withStep(`rename executable -> ${displayName}`, async () => {
352
+ if (dryRun) return;
353
+ if (fs.existsSync(oldExe)) await fsp.rename(oldExe, newExe);
354
+ else if (!fs.existsSync(newExe)) throw new Error(`expected ${oldExe} or ${newExe} to exist after clone-copy`);
355
+ });
356
+ const infoPlist = path.join(destApp, "Contents", "Info.plist");
357
+ await log.withStep(`patch Info.plist`, async () => {
358
+ if (dryRun) return;
359
+ const ops = [
360
+ {
361
+ key: "CFBundleName",
362
+ type: "string",
363
+ value: displayName
364
+ },
365
+ {
366
+ key: "CFBundleDisplayName",
367
+ type: "string",
368
+ value: displayName
369
+ },
370
+ {
371
+ key: "CFBundleExecutable",
372
+ type: "string",
373
+ value: displayName
374
+ },
375
+ {
376
+ key: "CFBundleIdentifier",
377
+ type: "string",
378
+ value: bundleId
379
+ },
380
+ {
381
+ key: "CFBundleIconFile",
382
+ type: "string",
383
+ value: "icon.icns"
384
+ },
385
+ {
386
+ key: "CFBundleVersion",
387
+ type: "string",
388
+ value: version
389
+ },
390
+ {
391
+ key: "CFBundleShortVersionString",
392
+ type: "string",
393
+ value: version
394
+ },
395
+ {
396
+ key: "NSHighResolutionCapable",
397
+ type: "bool",
398
+ value: true
399
+ },
400
+ {
401
+ key: "LSMinimumSystemVersion",
402
+ type: "string",
403
+ value: "12.0"
404
+ },
405
+ {
406
+ key: "NSSupportsAutomaticGraphicsSwitching",
407
+ type: "bool",
408
+ value: true
409
+ },
410
+ {
411
+ key: "NSRequiresAquaSystemAppearance",
412
+ type: "bool",
413
+ value: false
414
+ },
415
+ {
416
+ key: "NSPrincipalClass",
417
+ type: "string",
418
+ value: "AtomApplication"
419
+ }
420
+ ];
421
+ for (const op of ops) plutilReplace(infoPlist, op, log);
422
+ plutilRemove(infoPlist, ":ElectronAsarIntegrity", log);
423
+ });
424
+ const resourcesDir = path.join(destApp, "Contents", "Resources");
425
+ const iconDest = path.join(resourcesDir, "icon.icns");
426
+ await log.withStep(`install icon -> ${iconDest}`, async () => {
427
+ if (dryRun) return;
428
+ if (iconSource) await prepareIcon({
429
+ source: iconSource,
430
+ dest: iconDest,
431
+ log
432
+ });
433
+ else {
434
+ const fallback = path.join(resourcesDir, "electron.icns");
435
+ if (fs.existsSync(fallback)) await fsp.copyFile(fallback, iconDest);
436
+ else log.info(`no icon source and no electron.icns fallback; skipping`);
437
+ }
438
+ });
439
+ const bundleAppDir = path.join(resourcesDir, "app");
440
+ await log.withStep(`write Resources/app/{package.json,launcher.mjs,app-config.json,host.json}`, async () => {
441
+ if (dryRun) return;
442
+ await fsp.mkdir(bundleAppDir, { recursive: true });
443
+ const bundlePkg = {
444
+ name: slug,
445
+ version,
446
+ main: "launcher.mjs",
447
+ type: "module"
448
+ };
449
+ await fsp.writeFile(path.join(bundleAppDir, "package.json"), JSON.stringify(bundlePkg, null, 2) + "\n");
450
+ if (!fs.existsSync(launcherSource)) throw new Error(`launcher source missing: ${launcherSource}`);
451
+ await fsp.copyFile(launcherSource, path.join(bundleAppDir, "launcher.mjs"));
452
+ const appConfig = {
453
+ name: slug,
454
+ packageManager,
455
+ version,
456
+ local: true
457
+ };
458
+ await fsp.writeFile(path.join(bundleAppDir, "app-config.json"), JSON.stringify(appConfig, null, 2) + "\n");
459
+ await fsp.writeFile(path.join(bundleAppDir, "host.json"), JSON.stringify({ version }, null, 2) + "\n");
460
+ log.info(`appsDir baked into name = ${slug} (resolves to ${appsDir})`);
461
+ });
462
+ const entitlementsPath = path.join(os.tmpdir(), `czda-entitlements-${process.pid}-${Date.now()}.plist`);
463
+ await log.withStep(`ad-hoc codesign --deep`, async () => {
464
+ if (dryRun) return;
465
+ await fsp.writeFile(entitlementsPath, ENTITLEMENTS);
466
+ try {
467
+ const r = spawnSync("codesign", [
468
+ "--force",
469
+ "--deep",
470
+ "--sign",
471
+ "-",
472
+ "--entitlements",
473
+ entitlementsPath,
474
+ destApp
475
+ ], {
476
+ stdio: "pipe",
477
+ encoding: "utf8"
478
+ });
479
+ if (r.status !== 0) throw new Error(`codesign failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
480
+ log.info(r.stderr?.trim() || "codesign ok");
481
+ } finally {
482
+ await fsp.unlink(entitlementsPath).catch(() => {});
483
+ }
484
+ });
485
+ await log.withStep(`xattr -dr com.apple.quarantine`, async () => {
486
+ if (dryRun) return;
487
+ spawnSync("xattr", [
488
+ "-dr",
489
+ "com.apple.quarantine",
490
+ destApp
491
+ ], { stdio: "ignore" });
492
+ });
493
+ await log.withStep(`verify (codesign + plutil)`, async () => {
494
+ if (dryRun) return;
495
+ const cs = spawnSync("codesign", [
496
+ "--verify",
497
+ "--deep",
498
+ "--strict",
499
+ destApp
500
+ ], {
501
+ stdio: "pipe",
502
+ encoding: "utf8"
503
+ });
504
+ if (cs.status !== 0) throw new Error(`codesign verify failed (${cs.status}): ${cs.stderr?.slice(0, 1e3) ?? ""}`);
505
+ const pl = spawnSync("plutil", ["-lint", infoPlist], {
506
+ stdio: "pipe",
507
+ encoding: "utf8"
508
+ });
509
+ if (pl.status !== 0) throw new Error(`plutil -lint failed (${pl.status}): ${pl.stderr?.slice(0, 500) ?? ""} ${pl.stdout?.slice(0, 500) ?? ""}`);
510
+ });
511
+ }
512
+ function cloneCopy(src, dest, log) {
513
+ const r = spawnSync("cp", [
514
+ "-c",
515
+ "-R",
516
+ src,
517
+ dest
518
+ ], {
519
+ stdio: "pipe",
520
+ encoding: "utf8"
521
+ });
522
+ if (r.status === 0) return;
523
+ log.info(`cp -c failed, falling back to cp -R: ${r.stderr?.trim() ?? ""}`);
524
+ const r2 = spawnSync("cp", [
525
+ "-R",
526
+ src,
527
+ dest
528
+ ], {
529
+ stdio: "pipe",
530
+ encoding: "utf8"
531
+ });
532
+ if (r2.status !== 0) throw new Error(`cp failed (${r2.status}): ${r2.stderr?.slice(0, 1e3) ?? ""}`);
533
+ }
534
+ async function ensureParentDir(destApp, dryRun, log) {
535
+ const parent = path.dirname(destApp);
536
+ if (fs.existsSync(parent)) return;
537
+ if (dryRun) {
538
+ log.info(`[dry-run] would mkdir -p ${parent}`);
539
+ return;
540
+ }
541
+ await fsp.mkdir(parent, { recursive: true });
542
+ }
543
+ function plutilReplace(infoPlist, op, log) {
544
+ const value = String(op.value);
545
+ if (spawnSync("plutil", [
546
+ "-replace",
547
+ op.key,
548
+ `-${op.type}`,
549
+ value,
550
+ infoPlist
551
+ ], {
552
+ stdio: "pipe",
553
+ encoding: "utf8"
554
+ }).status === 0) return;
555
+ const insert = spawnSync("plutil", [
556
+ "-insert",
557
+ op.key,
558
+ `-${op.type}`,
559
+ value,
560
+ infoPlist
561
+ ], {
562
+ stdio: "pipe",
563
+ encoding: "utf8"
564
+ });
565
+ if (insert.status !== 0) throw new Error(`plutil -insert ${op.key} failed (${insert.status}): ${insert.stderr?.slice(0, 500) ?? ""}`);
566
+ log.info(`plutil inserted ${op.key}=${value}`);
567
+ }
568
+ function plutilRemove(infoPlist, keyPath, log) {
569
+ const r = spawnSync("plutil", [
570
+ "-remove",
571
+ keyPath,
572
+ infoPlist
573
+ ], {
574
+ stdio: "pipe",
575
+ encoding: "utf8"
576
+ });
577
+ if (r.status === 0) {
578
+ log.info(`plutil removed ${keyPath}`);
579
+ return;
580
+ }
581
+ log.info(`plutil -remove ${keyPath}: ${r.stderr?.trim() ?? "(no key)"}`);
582
+ }
583
+ /**
584
+ * Resolve the path to `@zenbujs/core/dist/launcher.mjs` from any cwd. We
585
+ * use `package.json` as the resolution anchor because that subpath is
586
+ * exported (see packages/core/package.json `exports`).
587
+ */
588
+ function resolveLauncherSource(fromDir) {
589
+ const req = createRequire(path.join(fromDir, "noop.js"));
590
+ let pkgPath;
591
+ try {
592
+ pkgPath = req.resolve("@zenbujs/core/package.json");
593
+ } catch (err) {
594
+ throw new Error(`failed to resolve @zenbujs/core from ${fromDir}: ${err.message}. Make sure create-zenbu-app's dependency on @zenbujs/core is installed.`);
595
+ }
596
+ const launcher = path.join(path.dirname(pkgPath), "dist", "launcher.mjs");
597
+ if (!fs.existsSync(launcher)) throw new Error(`@zenbujs/core launcher not found at ${launcher}. The package may be a stub or unbuilt.`);
598
+ return launcher;
599
+ }
600
+ //#endregion
601
+ //#region src/desktop/deps-sig.ts
602
+ function lockfileFor(type) {
603
+ switch (type) {
604
+ case "pnpm": return "pnpm-lock.yaml";
605
+ case "npm": return "package-lock.json";
606
+ case "yarn": return "yarn.lock";
607
+ case "bun": return "bun.lock";
608
+ }
609
+ }
610
+ /**
611
+ * Mirror of `packages/core/src/shared/pm-install.ts#depsSignature`, with
612
+ * `electronVersion` accepted explicitly because we run from Node (not
613
+ * Electron) and `process.versions.electron` is undefined here.
614
+ *
615
+ * Must stay byte-for-byte equivalent to the launcher's recipe so the
616
+ * pre-seeded `<appsDir>/.zenbu/deps-sig` matches what `ensureDepsInstalled`
617
+ * recomputes on first launch and the install gate skips.
618
+ */
619
+ async function depsSignature(opts) {
620
+ const hash = crypto.createHash("sha256");
621
+ await fileHash(hash, path.join(opts.appsDir, "package.json"));
622
+ await fileHash(hash, path.join(opts.appsDir, lockfileFor(opts.pm.type)));
623
+ hash.update(`${opts.pm.type}@${opts.pm.version}`);
624
+ hash.update("\0");
625
+ hash.update(opts.electronVersion);
626
+ hash.update("\0");
627
+ hash.update(opts.platform ?? process.platform);
628
+ hash.update("\0");
629
+ hash.update(opts.arch ?? process.arch);
630
+ return hash.digest("hex");
631
+ }
632
+ async function fileHash(hash, filePath) {
633
+ hash.update(filePath);
634
+ hash.update("\0");
635
+ try {
636
+ hash.update(await fsp.readFile(filePath));
637
+ } catch {}
638
+ hash.update("\0");
639
+ }
640
+ async function writeDepsSig(appsDir, sig) {
641
+ const sigPath = path.join(appsDir, ".zenbu", "deps-sig");
642
+ await fsp.mkdir(path.dirname(sigPath), { recursive: true });
643
+ await fsp.writeFile(sigPath, sig);
644
+ }
645
+ //#endregion
646
+ //#region src/desktop/log.ts
647
+ const LOG_DIR = path.join(os.homedir(), ".zenbu", "logs", "create-zenbu-app");
648
+ function ts() {
649
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
650
+ }
651
+ function createLogger(opts) {
652
+ fs.mkdirSync(LOG_DIR, { recursive: true });
653
+ const file = path.join(LOG_DIR, `${ts()}-${opts.slug}.log`);
654
+ const stream = fs.createWriteStream(file, { flags: "a" });
655
+ stream.write(`=== create-zenbu-app --desktop ${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} slug=${opts.slug} ===\n`);
656
+ const writeLine = (prefix, line) => {
657
+ const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${prefix}${line}\n`;
658
+ try {
659
+ stream.write(text);
660
+ } catch {}
661
+ if (opts.verbose) process.stdout.write(text);
662
+ };
663
+ return {
664
+ file,
665
+ step(label) {
666
+ writeLine("[STEP] ", label);
667
+ },
668
+ info(line) {
669
+ writeLine("", line);
670
+ },
671
+ error(line) {
672
+ const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] [ERR] ${line}\n`;
673
+ try {
674
+ stream.write(text);
675
+ } catch {}
676
+ process.stderr.write(text);
677
+ },
678
+ async withStep(label, fn) {
679
+ this.step(label);
680
+ const start = Date.now();
681
+ try {
682
+ const out = await fn();
683
+ writeLine("[STEP-OK] ", `${label} (${Date.now() - start}ms)`);
684
+ return out;
685
+ } catch (err) {
686
+ const e = err;
687
+ writeLine("[STEP-FAIL] ", `${label} (${Date.now() - start}ms): ${e.stack ?? e.message ?? String(err)}`);
688
+ throw err;
689
+ }
690
+ },
691
+ tail(n = 30) {
692
+ try {
693
+ const all = fs.readFileSync(file, "utf8").split("\n");
694
+ return all.slice(Math.max(0, all.length - n - 1)).join("\n");
695
+ } catch {
696
+ return "";
697
+ }
698
+ },
699
+ close() {
700
+ try {
701
+ stream.end();
702
+ } catch {}
703
+ }
704
+ };
705
+ }
706
+ //#endregion
707
+ //#region src/desktop/index.ts
708
+ /**
709
+ * Default destination when `destApp` not provided. We prefer `/Applications`
710
+ * but fall back to `~/Applications` when /Applications isn't writable
711
+ * (e.g. without admin rights or on locked-down setups).
712
+ */
713
+ function defaultDestApp(displayName) {
714
+ const sysApps = "/Applications";
715
+ try {
716
+ fs.accessSync(sysApps, fs.constants.W_OK);
717
+ return path.join(sysApps, `${displayName}.app`);
718
+ } catch {
719
+ const userApps = path.join(os.homedir(), "Applications");
720
+ return path.join(userApps, `${displayName}.app`);
721
+ }
722
+ }
723
+ async function buildDesktopApp(opts) {
724
+ const { displayName, slug, version, electronVersionRange, iconSource, appsDir, packageManager, resolveFrom, log, force = false, dryRun = false, skipDepsSig = false } = opts;
725
+ const destApp = opts.destApp ?? defaultDestApp(displayName);
726
+ const versionRange = electronVersionRange ?? await readElectronRangeFromAppsDir(appsDir) ?? "latest";
727
+ log.info(`displayName=${displayName} slug=${slug} version=${version}`);
728
+ log.info(`appsDir=${appsDir}`);
729
+ log.info(`destApp=${destApp}`);
730
+ log.info(`electronVersionRange=${versionRange}`);
731
+ const cache = await log.withStep(`ensure electron cache (${versionRange})`, () => ensureElectronApp({
732
+ versionRange,
733
+ log
734
+ }));
735
+ const launcherSource = resolveLauncherSource(resolveFrom);
736
+ log.info(`launcher source: ${launcherSource}`);
737
+ await synthesizeBundle({
738
+ cachedElectronApp: cache.electronAppPath,
739
+ destApp,
740
+ displayName,
741
+ slug,
742
+ version,
743
+ iconSource,
744
+ packageManager,
745
+ appsDir,
746
+ launcherSource,
747
+ log,
748
+ dryRun,
749
+ force
750
+ });
751
+ if (!skipDepsSig && !dryRun) await log.withStep(`write <appsDir>/.zenbu/deps-sig`, async () => {
752
+ const sig = await depsSignature({
753
+ appsDir,
754
+ pm: packageManager,
755
+ electronVersion: cache.version
756
+ });
757
+ await writeDepsSig(appsDir, sig);
758
+ log.info(`deps-sig=${sig.slice(0, 16)}…`);
759
+ });
760
+ else if (skipDepsSig) log.info(`skipping deps-sig (--no-install or skipDepsSig=true)`);
761
+ return {
762
+ destApp,
763
+ electronVersion: cache.version,
764
+ appsDir,
765
+ launchCommand: `open -a "${destApp}"`
766
+ };
767
+ }
768
+ async function readElectronRangeFromAppsDir(appsDir) {
769
+ try {
770
+ const raw = await fsp.readFile(path.join(appsDir, "package.json"), "utf8");
771
+ const pkg = JSON.parse(raw);
772
+ return pkg.devDependencies?.electron ?? pkg.dependencies?.electron ?? null;
773
+ } catch {
774
+ return null;
775
+ }
776
+ }
777
+ //#endregion
7
778
  //#region src/index.ts
8
779
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
780
  const TEMPLATES_DIR = path.resolve(__dirname, "..", "templates");
10
781
  const rawArgv = process.argv.slice(2);
11
782
  const flagsSet = /* @__PURE__ */ new Set();
783
+ const flagValues = /* @__PURE__ */ new Map();
12
784
  const positional = [];
13
785
  const dependsOn = [];
786
+ const VALUE_FLAGS = new Set([
787
+ "--icon",
788
+ "--electron-version",
789
+ "--bundle-id"
790
+ ]);
14
791
  /**
15
792
  * Parse flags. `--depends-on NAME=PATH` is consumed positionally because it
16
- * carries a value; everything else is a boolean flag or a positional arg.
793
+ * carries a value; `--icon <path>` / `--electron-version <range>` /
794
+ * `--bundle-id <id>` likewise consume the next argv entry; everything else
795
+ * is a boolean flag or a positional arg.
17
796
  */
18
797
  for (let i = 0; i < rawArgv.length; i++) {
19
798
  const arg = rawArgv[i];
@@ -25,7 +804,19 @@ for (let i = 0; i < rawArgv.length; i++) {
25
804
  }
26
805
  dependsOn.push(parseDependsOn(value));
27
806
  } else if (arg.startsWith("--depends-on=") || arg.startsWith("--dependsOn=")) dependsOn.push(parseDependsOn(arg.slice(arg.indexOf("=") + 1)));
28
- else if (arg.startsWith("-")) flagsSet.add(arg);
807
+ else if (VALUE_FLAGS.has(arg)) {
808
+ const value = rawArgv[++i];
809
+ if (!value) {
810
+ console.error(`create-zenbu-app: ${arg} requires a value`);
811
+ process.exit(1);
812
+ }
813
+ flagValues.set(arg, value);
814
+ } else if (arg.startsWith("--") && arg.includes("=")) {
815
+ const eq = arg.indexOf("=");
816
+ const name = arg.slice(0, eq);
817
+ if (VALUE_FLAGS.has(name)) flagValues.set(name, arg.slice(eq + 1));
818
+ else flagsSet.add(arg);
819
+ } else if (arg.startsWith("-")) flagsSet.add(arg);
29
820
  else positional.push(arg);
30
821
  }
31
822
  const yes = flagsSet.has("--yes") || flagsSet.has("-y");
@@ -33,6 +824,17 @@ const noInstall = flagsSet.has("--no-install");
33
824
  const noGit = flagsSet.has("--no-git");
34
825
  const pluginMode = flagsSet.has("--plugin");
35
826
  const noAddToHost = flagsSet.has("--no-add-to-host");
827
+ const desktopMode = flagsSet.has("--desktop");
828
+ const desktopForce = flagsSet.has("--force");
829
+ const desktopDryRun = flagsSet.has("--dry-run");
830
+ const desktopVerbose = flagsSet.has("--verbose");
831
+ const desktopIcon = flagValues.get("--icon");
832
+ const desktopElectronVersion = flagValues.get("--electron-version");
833
+ flagValues.get("--bundle-id");
834
+ if (desktopMode && pluginMode) {
835
+ console.error("create-zenbu-app: --desktop is not compatible with --plugin");
836
+ process.exit(1);
837
+ }
36
838
  if (dependsOn.length > 0 && !pluginMode) {
37
839
  console.error("create-zenbu-app: --depends-on is only valid with --plugin");
38
840
  process.exit(1);
@@ -429,7 +1231,164 @@ function defaultAnswers() {
429
1231
  for (const opt of CONFIG_OPTIONS) answers[opt.id] = opt.default;
430
1232
  return answers;
431
1233
  }
1234
+ function slugify(name) {
1235
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
1236
+ }
1237
+ async function promptIconPath() {
1238
+ const result = await p.text({
1239
+ message: "Path to app icon",
1240
+ placeholder: "(leave blank to use Electron's default icon)",
1241
+ validate: (value) => {
1242
+ if (!value || !value.trim()) return void 0;
1243
+ const abs = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
1244
+ if (!fs.existsSync(abs)) return `File does not exist: ${abs}`;
1245
+ const ext = path.extname(abs).toLowerCase();
1246
+ if (ext !== ".png" && ext !== ".icns") return "Icon must be a .png (square, ideally 1024x1024) or .icns file";
1247
+ }
1248
+ });
1249
+ if (p.isCancel(result)) bail("Scaffolding cancelled.");
1250
+ const trimmed = result.trim();
1251
+ if (!trimmed) return void 0;
1252
+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
1253
+ }
1254
+ const DESKTOP_PM_PRECEDENCE = [
1255
+ "pnpm",
1256
+ "bun",
1257
+ "yarn",
1258
+ "npm"
1259
+ ];
1260
+ function pickDesktopPm(detected) {
1261
+ for (const candidate of DESKTOP_PM_PRECEDENCE) {
1262
+ if (candidate === "npm") return {
1263
+ type: "npm",
1264
+ version: detected.version,
1265
+ fallback: false
1266
+ };
1267
+ const v = probeVersion(candidate);
1268
+ if (v) return {
1269
+ type: candidate,
1270
+ version: v,
1271
+ fallback: false
1272
+ };
1273
+ }
1274
+ return detected;
1275
+ }
1276
+ async function runDesktopMode() {
1277
+ p.intro("create-zenbu-app (desktop)");
1278
+ let displayName;
1279
+ if (positional[0]) displayName = positional[0];
1280
+ else if (yes) bail("--yes requires a positional project name in --desktop mode.");
1281
+ else displayName = await promptProjectName();
1282
+ const slug = slugify(displayName);
1283
+ if (!slug) bail(`"${displayName}" produces an empty slug; use letters/digits.`);
1284
+ const appsDir = path.join(os.homedir(), ".zenbu", "apps", slug);
1285
+ if (fs.existsSync(appsDir)) {
1286
+ if (fs.readdirSync(appsDir).length > 0) {
1287
+ if (!desktopForce) bail(`${appsDir} already exists. Re-run with --force to overwrite (this will rm -rf both ${appsDir} and the bundle).`);
1288
+ if (desktopDryRun) p.log.info(`[dry-run] would rm -rf ${appsDir}`);
1289
+ else fs.rmSync(appsDir, {
1290
+ recursive: true,
1291
+ force: true
1292
+ });
1293
+ }
1294
+ }
1295
+ let iconPath = desktopIcon ? path.isAbsolute(desktopIcon) ? desktopIcon : path.resolve(process.cwd(), desktopIcon) : void 0;
1296
+ if (!iconPath && !yes) iconPath = await promptIconPath();
1297
+ if (iconPath && !fs.existsSync(iconPath)) bail(`--icon path does not exist: ${iconPath}`);
1298
+ const slugTemplate = "tailwind";
1299
+ const templateDir = path.join(TEMPLATES_DIR, slugTemplate);
1300
+ if (!fs.existsSync(templateDir)) bail(`No template found for configuration "${slugTemplate}".`);
1301
+ const pm = pickDesktopPm(detectPackageManager());
1302
+ const log = createLogger({
1303
+ slug,
1304
+ verbose: desktopVerbose
1305
+ });
1306
+ log.info(`displayName=${displayName} slug=${slug}`);
1307
+ log.info(`template=${slugTemplate} pm=${pm.type}@${pm.version}`);
1308
+ log.info(`appsDir=${appsDir}`);
1309
+ log.info(`logFile=${log.file}`);
1310
+ const spinner = p.spinner();
1311
+ spinner.start(`Creating ${displayName}`);
1312
+ try {
1313
+ if (desktopDryRun) log.info(`[dry-run] would scaffold template into ${appsDir}`);
1314
+ else {
1315
+ fs.mkdirSync(appsDir, { recursive: true });
1316
+ copyDirSync(templateDir, appsDir, { projectName: slug });
1317
+ const gi = path.join(appsDir, "_gitignore");
1318
+ if (fs.existsSync(gi)) fs.renameSync(gi, path.join(appsDir, ".gitignore"));
1319
+ seedPackageManager(appsDir, pm);
1320
+ if (ZENBU_LOCAL_CORE) {
1321
+ const corePath = path.resolve(ZENBU_LOCAL_CORE);
1322
+ rewireToLocalCore(appsDir, corePath, false);
1323
+ log.info(`linked @zenbujs/core -> ${corePath}`);
1324
+ }
1325
+ }
1326
+ let installed = false;
1327
+ if (!noInstall && !desktopDryRun) {
1328
+ log.info(`running ${pm.type} install in ${appsDir}`);
1329
+ installed = runInstallSilent(appsDir, pm, log);
1330
+ if (!installed) {
1331
+ spinner.stop(`Failed during ${pm.type} install. See ${log.file}`);
1332
+ process.stderr.write(log.tail(40) + "\n");
1333
+ process.exit(1);
1334
+ }
1335
+ }
1336
+ if (!desktopDryRun) gitInitWithInitialCommit(appsDir);
1337
+ const projectVersion = readProjectVersion(appsDir) ?? "0.0.1";
1338
+ const result = await buildDesktopApp({
1339
+ displayName,
1340
+ slug,
1341
+ version: projectVersion,
1342
+ electronVersionRange: desktopElectronVersion,
1343
+ iconSource: iconPath,
1344
+ appsDir,
1345
+ packageManager: pm,
1346
+ resolveFrom: appsDir,
1347
+ log,
1348
+ force: desktopForce,
1349
+ dryRun: desktopDryRun,
1350
+ skipDepsSig: noInstall
1351
+ });
1352
+ spinner.stop(`Created ${displayName}`);
1353
+ log.close();
1354
+ p.note([
1355
+ `App: ${result.destApp}`,
1356
+ `Source: ${result.appsDir}`,
1357
+ `Logs: ~/Library/Logs/${displayName}/main.log`
1358
+ ].join("\n"), "Details");
1359
+ p.outro(`Launch with: ${result.launchCommand}`);
1360
+ } catch (err) {
1361
+ spinner.stop(`Failed: see ${log.file}`);
1362
+ log.error(err.stack ?? err.message ?? String(err));
1363
+ process.stderr.write("\n--- last log lines ---\n" + log.tail(40) + "\n");
1364
+ log.close();
1365
+ process.exit(1);
1366
+ }
1367
+ }
1368
+ function readProjectVersion(dir) {
1369
+ try {
1370
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
1371
+ const pkg = JSON.parse(raw);
1372
+ return typeof pkg.version === "string" ? pkg.version : null;
1373
+ } catch {
1374
+ return null;
1375
+ }
1376
+ }
1377
+ function runInstallSilent(projectDir, pm, log) {
1378
+ const r = spawnSync(pm.type, ["install"], {
1379
+ cwd: projectDir,
1380
+ stdio: "pipe",
1381
+ encoding: "utf8"
1382
+ });
1383
+ if (r.stdout) log.info(r.stdout);
1384
+ if (r.stderr) log.info(r.stderr);
1385
+ return r.status === 0;
1386
+ }
432
1387
  async function main() {
1388
+ if (desktopMode) {
1389
+ await runDesktopMode();
1390
+ return;
1391
+ }
433
1392
  p.intro(pluginMode ? "create-zenbu-app (plugin)" : "create-zenbu-app");
434
1393
  let projectName;
435
1394
  if (positional[0]) projectName = positional[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zenbu-app",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Scaffold a new Zenbu app",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -33,6 +33,7 @@
33
33
  "@clack/prompts": "^1.3.0"
34
34
  },
35
35
  "scripts": {
36
+ "test:desktop": "bash scripts/test-desktop.sh",
36
37
  "build": "pnpm run build:agents && tsdown",
37
38
  "build:agents": "sh -c 'for d in vanilla tailwind plugin; do { printf \"# Zenbu.js docs\\n\\n\"; curl -fsSL https://zenbulabs.mintlify.app/llms-full.txt; printf \"\\n\\n# General rules\\n\\n- never use any, every api in the app is fully type safe\\n- use zen link after making changes to sync types if it did not happen automatically\\n- always use <pm> run db:generate after making a schema change otherwise it will not take affect\\n- always prefer to make db updates via the replica so that updates are instant. if you used rpc to update a replica it would serve 0 purpose and just add an extra round trip\\n- prefer one component per file\\n\"; } > templates/$d/AGENTS.md; done'",
38
39
  "typecheck": "tsc --noEmit -p tsconfig.json"
@@ -56,7 +56,7 @@ import {
56
56
 
57
57
  ```bash theme={null}
58
58
  # Create a new app
59
- pnpx create-zenbu-app my-app
59
+ npx create-zenbu-app
60
60
 
61
61
  # Dev server with hot reload
62
62
  pnpm run dev
@@ -1287,15 +1287,15 @@ Source: https://zenbulabs.mintlify.app/quickstart
1287
1287
  <Steps>
1288
1288
  <Step title="Scaffold the project">
1289
1289
  <Tabs>
1290
- <Tab title="pnpm">
1290
+ <Tab title="npm">
1291
1291
  ```bash theme={null}
1292
- pnpx create-zenbu-app my-app
1292
+ npx create-zenbu-app my-app
1293
1293
  ```
1294
1294
  </Tab>
1295
1295
 
1296
- <Tab title="npm">
1296
+ <Tab title="pnpm">
1297
1297
  ```bash theme={null}
1298
- npx create-zenbu-app my-app
1298
+ pnpx create-zenbu-app my-app
1299
1299
  ```
1300
1300
  </Tab>
1301
1301
 
@@ -13,7 +13,7 @@
13
13
  "react-dom": ">=18"
14
14
  },
15
15
  "devDependencies": {
16
- "@zenbujs/core": "^0.0.20",
16
+ "@zenbujs/core": "0.0.23",
17
17
  "@types/node": "^22.0.0",
18
18
  "@types/react": "^19.0.0",
19
19
  "@types/react-dom": "^19.0.0",
@@ -56,7 +56,7 @@ import {
56
56
 
57
57
  ```bash theme={null}
58
58
  # Create a new app
59
- pnpx create-zenbu-app my-app
59
+ npx create-zenbu-app
60
60
 
61
61
  # Dev server with hot reload
62
62
  pnpm run dev
@@ -1287,15 +1287,15 @@ Source: https://zenbulabs.mintlify.app/quickstart
1287
1287
  <Steps>
1288
1288
  <Step title="Scaffold the project">
1289
1289
  <Tabs>
1290
- <Tab title="pnpm">
1290
+ <Tab title="npm">
1291
1291
  ```bash theme={null}
1292
- pnpx create-zenbu-app my-app
1292
+ npx create-zenbu-app my-app
1293
1293
  ```
1294
1294
  </Tab>
1295
1295
 
1296
- <Tab title="npm">
1296
+ <Tab title="pnpm">
1297
1297
  ```bash theme={null}
1298
- npx create-zenbu-app my-app
1298
+ pnpx create-zenbu-app my-app
1299
1299
  ```
1300
1300
  </Tab>
1301
1301
 
@@ -12,7 +12,7 @@
12
12
  "db:generate": "zen db generate"
13
13
  },
14
14
  "dependencies": {
15
- "@zenbujs/core": "^0.0.20",
15
+ "@zenbujs/core": "0.0.23",
16
16
  "@tailwindcss/vite": "^4.2.0",
17
17
  "@vitejs/plugin-react": "^5.0.0",
18
18
  "react": "^19.0.0",
@@ -56,7 +56,7 @@ import {
56
56
 
57
57
  ```bash theme={null}
58
58
  # Create a new app
59
- pnpx create-zenbu-app my-app
59
+ npx create-zenbu-app
60
60
 
61
61
  # Dev server with hot reload
62
62
  pnpm run dev
@@ -1287,15 +1287,15 @@ Source: https://zenbulabs.mintlify.app/quickstart
1287
1287
  <Steps>
1288
1288
  <Step title="Scaffold the project">
1289
1289
  <Tabs>
1290
- <Tab title="pnpm">
1290
+ <Tab title="npm">
1291
1291
  ```bash theme={null}
1292
- pnpx create-zenbu-app my-app
1292
+ npx create-zenbu-app my-app
1293
1293
  ```
1294
1294
  </Tab>
1295
1295
 
1296
- <Tab title="npm">
1296
+ <Tab title="pnpm">
1297
1297
  ```bash theme={null}
1298
- npx create-zenbu-app my-app
1298
+ pnpx create-zenbu-app my-app
1299
1299
  ```
1300
1300
  </Tab>
1301
1301
 
@@ -12,7 +12,7 @@
12
12
  "db:generate": "zen db generate"
13
13
  },
14
14
  "dependencies": {
15
- "@zenbujs/core": "^0.0.20",
15
+ "@zenbujs/core": "0.0.23",
16
16
  "@vitejs/plugin-react": "^5.0.0",
17
17
  "react": "^19.0.0",
18
18
  "react-dom": "^19.0.0",