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 +961 -2
- package/package.json +2 -1
- package/templates/plugin/AGENTS.md +5 -5
- package/templates/plugin/package.json +1 -1
- package/templates/tailwind/AGENTS.md +5 -5
- package/templates/tailwind/package.json +1 -1
- package/templates/vanilla/AGENTS.md +5 -5
- package/templates/vanilla/package.json +1 -1
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;
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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="
|
|
1290
|
+
<Tab title="npm">
|
|
1291
1291
|
```bash theme={null}
|
|
1292
|
-
|
|
1292
|
+
npx create-zenbu-app my-app
|
|
1293
1293
|
```
|
|
1294
1294
|
</Tab>
|
|
1295
1295
|
|
|
1296
|
-
<Tab title="
|
|
1296
|
+
<Tab title="pnpm">
|
|
1297
1297
|
```bash theme={null}
|
|
1298
|
-
|
|
1298
|
+
pnpx create-zenbu-app my-app
|
|
1299
1299
|
```
|
|
1300
1300
|
</Tab>
|
|
1301
1301
|
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
|
|
57
57
|
```bash theme={null}
|
|
58
58
|
# Create a new app
|
|
59
|
-
|
|
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="
|
|
1290
|
+
<Tab title="npm">
|
|
1291
1291
|
```bash theme={null}
|
|
1292
|
-
|
|
1292
|
+
npx create-zenbu-app my-app
|
|
1293
1293
|
```
|
|
1294
1294
|
</Tab>
|
|
1295
1295
|
|
|
1296
|
-
<Tab title="
|
|
1296
|
+
<Tab title="pnpm">
|
|
1297
1297
|
```bash theme={null}
|
|
1298
|
-
|
|
1298
|
+
pnpx create-zenbu-app my-app
|
|
1299
1299
|
```
|
|
1300
1300
|
</Tab>
|
|
1301
1301
|
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
|
|
57
57
|
```bash theme={null}
|
|
58
58
|
# Create a new app
|
|
59
|
-
|
|
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="
|
|
1290
|
+
<Tab title="npm">
|
|
1291
1291
|
```bash theme={null}
|
|
1292
|
-
|
|
1292
|
+
npx create-zenbu-app my-app
|
|
1293
1293
|
```
|
|
1294
1294
|
</Tab>
|
|
1295
1295
|
|
|
1296
|
-
<Tab title="
|
|
1296
|
+
<Tab title="pnpm">
|
|
1297
1297
|
```bash theme={null}
|
|
1298
|
-
|
|
1298
|
+
pnpx create-zenbu-app my-app
|
|
1299
1299
|
```
|
|
1300
1300
|
</Tab>
|
|
1301
1301
|
|