sparkbun 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Build fast, lightweight, cross-platform desktop apps with TypeScript and Bun.",
5
5
  "license": "MIT",
6
6
  "author": "SparkBun Contributors",
package/src/cli/index.ts CHANGED
@@ -1663,6 +1663,17 @@ ${utiDecls}
1663
1663
  }
1664
1664
  await runAppWithSignalHandling(config);
1665
1665
  }
1666
+ } else if (commandArg === "installer") {
1667
+ const config = await getConfig();
1668
+ const envArg =
1669
+ process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || "";
1670
+ const buildEnvironment: "dev" | "stable" = envArg === "dev" ? "dev" : "stable";
1671
+ try {
1672
+ await runInstallerBuild(config, buildEnvironment);
1673
+ } catch (error) {
1674
+ console.error("Installer build failed:", error);
1675
+ process.exit(1);
1676
+ }
1666
1677
  }
1667
1678
 
1668
1679
  async function runBuild(
@@ -3187,6 +3198,394 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3187
3198
  // stapler validate -v <app path>
3188
3199
  }
3189
3200
 
3201
+ async function runInstallerBuild(
3202
+ config: Awaited<ReturnType<typeof getConfig>>,
3203
+ buildEnvironment: "dev" | "stable",
3204
+ ) {
3205
+ // --- Parse installer-specific CLI flags ---
3206
+
3207
+ const archives: { name: string; path: string }[] = [];
3208
+ for (let i = 0; i < process.argv.length; i++) {
3209
+ if (process.argv[i] === "--archive" && process.argv[i + 1]) {
3210
+ const arg = process.argv[i + 1]!;
3211
+ const eqIdx = arg.indexOf("=");
3212
+ if (eqIdx === -1) {
3213
+ console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
3214
+ process.exit(1);
3215
+ }
3216
+ const name = arg.substring(0, eqIdx);
3217
+ const archivePath = arg.substring(eqIdx + 1);
3218
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
3219
+ console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
3220
+ process.exit(1);
3221
+ }
3222
+ if (archives.some((a) => a.name === name)) {
3223
+ console.error(`Duplicate archive name: "${name}".`);
3224
+ process.exit(1);
3225
+ }
3226
+ const resolvedPath = path.resolve(projectRoot, archivePath);
3227
+ if (!existsSync(resolvedPath)) {
3228
+ console.error(`Archive path does not exist: ${resolvedPath}`);
3229
+ process.exit(1);
3230
+ }
3231
+ archives.push({ name, path: resolvedPath });
3232
+ }
3233
+ }
3234
+
3235
+ const getFlag = (prefix: string): string | undefined =>
3236
+ process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
3237
+
3238
+ const outPath = getFlag("--out=");
3239
+ const iconPath = getFlag("--icon=");
3240
+ const installerName = getFlag("--name=") || config.app.name;
3241
+ const installerVersion = getFlag("--version=") || config.app.version;
3242
+ const installerPublisher = getFlag("--publisher=") || (config.app as any).publisher || "";
3243
+ const metadataPath = getFlag("--metadata=");
3244
+
3245
+ // Determine target platform
3246
+ const targetArg = getFlag("--target=");
3247
+ let targetOS: "macos" | "win" | "linux" = OS;
3248
+ let targetARCH: "arm64" | "x64" = ARCH;
3249
+ if (targetArg) {
3250
+ const [tOS, tArch] = targetArg.split("-") as [string, string];
3251
+ const osMap: Record<string, "macos" | "win" | "linux"> = { macos: "macos", darwin: "macos", win: "win", windows: "win", linux: "linux" };
3252
+ const archMap: Record<string, "arm64" | "x64"> = { arm64: "arm64", aarch64: "arm64", x64: "x64", x86_64: "x64" };
3253
+ if (osMap[tOS] && archMap[tArch]) {
3254
+ targetOS = osMap[tOS];
3255
+ targetARCH = archMap[tArch];
3256
+ console.log(`Cross-compiling installer for ${targetOS}-${targetARCH}`);
3257
+ } else {
3258
+ console.error(`Invalid target: ${targetArg}. Use format: win-x64, linux-arm64, macos-arm64`);
3259
+ process.exit(1);
3260
+ }
3261
+ }
3262
+
3263
+ const targetBinExt = targetOS === "win" ? ".exe" : "";
3264
+
3265
+ console.log(`Building installer: ${installerName} v${installerVersion}`);
3266
+ if (archives.length > 0) {
3267
+ console.log(`Archives: ${archives.map((a) => `${a.name}=${a.path}`).join(", ")}`);
3268
+ }
3269
+
3270
+ // --- Ensure native dependencies ---
3271
+
3272
+ await ensureCoreDependencies(targetOS, targetARCH);
3273
+ const targetPaths = getPlatformPaths(targetOS, targetARCH);
3274
+
3275
+ // --- Set up build folder ---
3276
+
3277
+ const platformPrefix = getPlatformPrefix(buildEnvironment, targetOS, targetARCH);
3278
+ const buildFolder = join(projectRoot, config.build.buildFolder, `installer-${platformPrefix}`);
3279
+
3280
+ if (existsSync(buildFolder)) {
3281
+ rmSync(buildFolder, { recursive: true, force: true });
3282
+ }
3283
+ mkdirSync(buildFolder, { recursive: true });
3284
+
3285
+ // --- Create app bundle ---
3286
+
3287
+ const appFileName = getAppFileName(config.app.name, buildEnvironment);
3288
+ const bundle = createAppBundle(appFileName, buildFolder, targetOS);
3289
+ const appBundleFolderPath = bundle.appBundleFolderPath;
3290
+ const appBundleMacOSPath = bundle.appBundleMacOSPath;
3291
+ const appBundleFolderResourcesPath = bundle.appBundleFolderResourcesPath;
3292
+ const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
3293
+ mkdirSync(appBundleAppCodePath, { recursive: true });
3294
+
3295
+ // --- Compile launcher ---
3296
+
3297
+ const launcherBinaryName = config.app.name.replace(/ /g, "");
3298
+ const launcherDestination = join(appBundleMacOSPath, launcherBinaryName) + targetBinExt;
3299
+ mkdirSync(dirname(launcherDestination), { recursive: true });
3300
+
3301
+ const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
3302
+ const sparkbunRoot = join(cliDir, "..", "..");
3303
+ const launcherSourcePath = join(sparkbunRoot, "src", "launcher", "main.ts");
3304
+ const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${targetARCH}` as const;
3305
+
3306
+ const compileOptions: any = {
3307
+ target: bunTarget,
3308
+ outfile: launcherDestination,
3309
+ };
3310
+
3311
+ if (targetOS === "win" && OS === "win") {
3312
+ let icoPath: string | undefined;
3313
+ if (config.build.win?.icon) {
3314
+ const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
3315
+ ? config.build.win.icon
3316
+ : join(projectRoot, config.build.win.icon);
3317
+ if (existsSync(iconSrc)) {
3318
+ icoPath = iconSrc;
3319
+ if (iconSrc.toLowerCase().endsWith(".png")) {
3320
+ const pngToIco = (await import("png-to-ico")).default;
3321
+ const tempIcoPath = join(buildFolder, "temp-launcher-icon.ico");
3322
+ const icoBuffer = await pngToIco(iconSrc);
3323
+ writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
3324
+ icoPath = tempIcoPath;
3325
+ }
3326
+ }
3327
+ }
3328
+ compileOptions.windows = {
3329
+ hideConsole: true,
3330
+ ...(icoPath && { icon: icoPath }),
3331
+ title: config.app.name,
3332
+ version: installerVersion,
3333
+ description: config.app.name,
3334
+ publisher: installerPublisher || " ",
3335
+ copyright: (config.app as any).copyright || " ",
3336
+ };
3337
+ }
3338
+
3339
+ console.log("Compiling installer launcher...");
3340
+ const launcherBuild = await Bun.build({
3341
+ entrypoints: [launcherSourcePath],
3342
+ compile: compileOptions,
3343
+ });
3344
+ if (!launcherBuild.success) {
3345
+ console.error("Launcher compilation failed:", launcherBuild.logs);
3346
+ throw new Error("Launcher compilation failed");
3347
+ }
3348
+
3349
+ if (targetOS === "win") {
3350
+ patchPeSubsystem(launcherDestination);
3351
+ }
3352
+
3353
+ // --- Copy native wrappers ---
3354
+
3355
+ if (targetOS === "win") {
3356
+ cpSync(targetPaths.CORE_WIN, join(appBundleMacOSPath, "SparkBunCore.dll"), { dereference: true });
3357
+ cpSync(targetPaths.NATIVE_WRAPPER_WIN, join(appBundleMacOSPath, "libNativeWrapper.dll"), { dereference: true });
3358
+ cpSync(targetPaths.WEBVIEW2LOADER_WIN, join(appBundleMacOSPath, "WebView2Loader.dll"), { dereference: true });
3359
+ } else if (targetOS === "macos") {
3360
+ cpSync(targetPaths.CORE_MACOS, join(appBundleMacOSPath, "libSparkBunCore.dylib"), { dereference: true });
3361
+ cpSync(targetPaths.NATIVE_WRAPPER_MACOS, join(appBundleMacOSPath, "libNativeWrapper.dylib"), { dereference: true });
3362
+ } else if (targetOS === "linux") {
3363
+ cpSync(targetPaths.CORE_LINUX, join(appBundleMacOSPath, "libSparkBunCore.so"), { dereference: true });
3364
+ cpSync(targetPaths.NATIVE_WRAPPER_LINUX, join(appBundleMacOSPath, "libNativeWrapper.so"), { dereference: true });
3365
+ }
3366
+
3367
+ // libasar stub
3368
+ const libExt = targetOS === "win" ? ".dll" : targetOS === "macos" ? ".dylib" : ".so";
3369
+ const platformDistDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`);
3370
+ const asarLibSource = join(platformDistDir, "libasar" + libExt);
3371
+ if (existsSync(asarLibSource)) {
3372
+ cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), { dereference: true });
3373
+ }
3374
+
3375
+ // --- Bundle bun code ---
3376
+
3377
+ const bunConfig = config.build.bun;
3378
+ const bunSource = join(projectRoot, bunConfig.entrypoint);
3379
+ if (!existsSync(bunSource)) {
3380
+ throw new Error(`Bun entrypoint not found: ${bunSource}`);
3381
+ }
3382
+
3383
+ const bunDestFolder = join(appBundleAppCodePath, "bun");
3384
+ const { entrypoint: _bunEntrypoint, ...bunBuildOptions } = bunConfig;
3385
+ const bunBuildResult = await Bun.build({
3386
+ ...bunBuildOptions,
3387
+ entrypoints: [bunSource],
3388
+ outdir: bunDestFolder,
3389
+ target: "bun",
3390
+ });
3391
+ if (!bunBuildResult.success) {
3392
+ console.error("Failed to build bun entrypoint:", bunSource);
3393
+ printBuildLogs(bunBuildResult.logs);
3394
+ throw new Error("Build failed: bun build failed");
3395
+ }
3396
+
3397
+ // --- Bundle views ---
3398
+
3399
+ for (const viewName in config.build.views) {
3400
+ const viewConfig = config.build.views[viewName]!;
3401
+ const viewSource = join(projectRoot, viewConfig.entrypoint);
3402
+ if (!existsSync(viewSource)) {
3403
+ console.error(`View entrypoint not found: ${viewSource}`);
3404
+ continue;
3405
+ }
3406
+
3407
+ const viewDestFolder = join(appBundleAppCodePath, "views", viewName);
3408
+ mkdirSync(viewDestFolder, { recursive: true });
3409
+
3410
+ const { entrypoint: _viewEntrypoint, ...viewBuildOptions } = viewConfig;
3411
+ const viewBuildResult = await Bun.build({
3412
+ ...viewBuildOptions,
3413
+ entrypoints: [viewSource],
3414
+ outdir: viewDestFolder,
3415
+ target: "browser",
3416
+ });
3417
+ if (!viewBuildResult.success) {
3418
+ console.error("Failed to build view:", viewSource);
3419
+ printBuildLogs(viewBuildResult.logs);
3420
+ }
3421
+ }
3422
+
3423
+ // --- Copy assets ---
3424
+
3425
+ for (const relSource in config.build.copy) {
3426
+ const source = join(projectRoot, relSource);
3427
+ if (!existsSync(source)) {
3428
+ console.error(`Asset not found: ${source}`);
3429
+ continue;
3430
+ }
3431
+
3432
+ const destination = join(appBundleAppCodePath, config.build.copy[relSource]!);
3433
+ const destFolder = dirname(destination);
3434
+ if (!existsSync(destFolder)) {
3435
+ mkdirSync(destFolder, { recursive: true });
3436
+ }
3437
+
3438
+ cpSync(source, destination, { recursive: true, dereference: true });
3439
+ }
3440
+
3441
+ // --- Write build.json ---
3442
+
3443
+ const buildJson = {
3444
+ name: config.app.name,
3445
+ identifier: config.app.identifier,
3446
+ version: installerVersion,
3447
+ channel: buildEnvironment,
3448
+ ...config.runtime,
3449
+ };
3450
+ await Bun.write(join(appBundleAppCodePath, "build.json"), JSON.stringify(buildJson, null, 2));
3451
+
3452
+ // --- Create and embed archives ---
3453
+
3454
+ if (archives.length > 0) {
3455
+ console.log("Creating archives...");
3456
+ const archiveManifest: Record<string, string> = {};
3457
+
3458
+ for (const { name, path: archiveSrcPath } of archives) {
3459
+ const archiveFileName = `archive-${name}.tar.gz`;
3460
+ const archiveDestPath = join(appBundleFolderResourcesPath, archiveFileName);
3461
+
3462
+ console.log(` Archiving ${name}: ${archiveSrcPath}`);
3463
+ const tarPath = join(buildFolder, `archive-${name}.tar`);
3464
+ createTar(tarPath, dirname(archiveSrcPath), [basename(archiveSrcPath)]);
3465
+
3466
+ const tarBytes = readFileSync(tarPath);
3467
+ const gzipped = Bun.gzipSync(tarBytes);
3468
+ writeFileSync(archiveDestPath, gzipped);
3469
+
3470
+ // Clean up intermediate tar
3471
+ unlinkSync(tarPath);
3472
+
3473
+ const gzSize = statSync(archiveDestPath).size;
3474
+ console.log(` ${name}: ${(gzSize / 1024 / 1024).toFixed(2)} MB`);
3475
+ archiveManifest[name] = archiveFileName;
3476
+ }
3477
+
3478
+ // Write archives.json manifest
3479
+ await Bun.write(
3480
+ join(appBundleFolderResourcesPath, "archives.json"),
3481
+ JSON.stringify(archiveManifest, null, 2),
3482
+ );
3483
+ console.log(`Archives manifest written (${archives.length} archives)`);
3484
+ }
3485
+
3486
+ // --- Tar + gzip the app bundle ---
3487
+
3488
+ console.log("Packaging app bundle...");
3489
+ const tarPath = join(buildFolder, `${appFileName}.tar`);
3490
+ createTar(tarPath, buildFolder, [basename(appBundleFolderPath)]);
3491
+
3492
+ const compressedTarPath = `${tarPath}.gz`;
3493
+ const tarBytes = readFileSync(tarPath);
3494
+ const gzipped = Bun.gzipSync(tarBytes);
3495
+ writeFileSync(compressedTarPath, gzipped);
3496
+ unlinkSync(tarPath);
3497
+
3498
+ // Clean up the uncompressed app bundle
3499
+ rmSync(appBundleFolderPath, { recursive: true });
3500
+
3501
+ // --- Compile outer installer exe ---
3502
+
3503
+ const defaultOutName = `${installerName.replace(/ /g, "-")}-Setup${targetBinExt}`;
3504
+ const outputPath = outPath
3505
+ ? path.resolve(projectRoot, outPath)
3506
+ : join(buildFolder, defaultOutName);
3507
+
3508
+ console.log("Compiling installer executable...");
3509
+ const stagingDir = join(buildFolder, ".installer-wrapper-staging");
3510
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3511
+ mkdirSync(stagingDir, { recursive: true });
3512
+
3513
+ // Copy app bundle archive
3514
+ copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
3515
+
3516
+ // Write metadata
3517
+ const metadata: Record<string, unknown> = {
3518
+ name: installerName,
3519
+ version: installerVersion,
3520
+ identifier: config.app.identifier,
3521
+ channel: buildEnvironment,
3522
+ };
3523
+ if (metadataPath) {
3524
+ const userMetadata = JSON.parse(readFileSync(path.resolve(projectRoot, metadataPath), "utf-8"));
3525
+ Object.assign(metadata, userMetadata);
3526
+ }
3527
+ writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3528
+
3529
+ // Copy wrapper template
3530
+ const wrapperTemplatePath = join(cliDir, "..", "installer", "installer-wrapper-template.ts");
3531
+ copyFileSync(wrapperTemplatePath, join(stagingDir, "installer.ts"));
3532
+
3533
+ const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
3534
+ const isWindows = targetOS === "win";
3535
+
3536
+ const installerCompileOptions: any = {
3537
+ target: `bun-${targetOSName}-${targetARCH}`,
3538
+ outfile: outputPath,
3539
+ };
3540
+
3541
+ if (isWindows && OS === "win") {
3542
+ let icoPath: string | undefined;
3543
+ if (iconPath) {
3544
+ const resolvedIcon = path.resolve(projectRoot, iconPath);
3545
+ if (existsSync(resolvedIcon)) {
3546
+ icoPath = resolvedIcon;
3547
+ if (resolvedIcon.toLowerCase().endsWith(".png")) {
3548
+ const pngToIco = (await import("png-to-ico")).default;
3549
+ const tempIcoPath = join(buildFolder, "temp-installer-icon.ico");
3550
+ const icoBuffer = await pngToIco(resolvedIcon);
3551
+ writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
3552
+ icoPath = tempIcoPath;
3553
+ }
3554
+ }
3555
+ }
3556
+ installerCompileOptions.windows = {
3557
+ hideConsole: true,
3558
+ ...(icoPath && { icon: icoPath }),
3559
+ title: `${installerName} Setup`,
3560
+ version: installerVersion,
3561
+ description: `Installs ${installerName}`,
3562
+ publisher: installerPublisher || " ",
3563
+ copyright: (config.app as any).copyright || " ",
3564
+ };
3565
+ }
3566
+
3567
+ const installerBuild = await Bun.build({
3568
+ entrypoints: [join(stagingDir, "installer.ts")],
3569
+ compile: installerCompileOptions,
3570
+ });
3571
+ if (!installerBuild.success) {
3572
+ console.error("Installer compilation failed:", installerBuild.logs);
3573
+ throw new Error("Installer compilation failed");
3574
+ }
3575
+
3576
+ if (isWindows) {
3577
+ patchPeSubsystem(outputPath);
3578
+ } else {
3579
+ execSync(`chmod +x ${escapePathForTerminal(outputPath)}`);
3580
+ }
3581
+
3582
+ // Clean up staging
3583
+ rmSync(stagingDir, { recursive: true, force: true });
3584
+
3585
+ const exeSize = statSync(outputPath).size;
3586
+ console.log(`\nInstaller built: ${outputPath} (${(exeSize / 1024 / 1024).toFixed(2)} MB)`);
3587
+ }
3588
+
3190
3589
  // Take over as the terminal's foreground process group (macOS/Linux).
3191
3590
  // This prevents the parent bun script runner from receiving SIGINT
3192
3591
  // when Ctrl+C is pressed, keeping the terminal busy until the app
@@ -0,0 +1,69 @@
1
+ import archiveData from "./app-archive.tar.gz" with { type: "file" };
2
+ import metadataJson from "./metadata.json" with { type: "file" };
3
+ import { join } from "path";
4
+ import { existsSync, mkdirSync, rmSync } from "fs";
5
+
6
+ interface Metadata {
7
+ name: string;
8
+ version?: string;
9
+ }
10
+
11
+ const metadata: Metadata = await Bun.file(metadataJson).json();
12
+ const tempDir = join(
13
+ process.env.TEMP || process.env.TMP || process.env.LOCALAPPDATA || ".",
14
+ `.sparkbun-installer-${Date.now()}`,
15
+ );
16
+
17
+ try {
18
+ console.log(`Starting ${metadata.name} installer...`);
19
+
20
+ const archiveBytes = await Bun.file(archiveData).bytes();
21
+ const archive = new Bun.Archive(archiveBytes);
22
+ mkdirSync(tempDir, { recursive: true });
23
+ await archive.extract(tempDir);
24
+
25
+ // Find the launcher exe inside the extracted bundle
26
+ const { readdirSync } = await import("fs");
27
+ let appDir = tempDir;
28
+ const entries = readdirSync(tempDir);
29
+ for (const entry of entries) {
30
+ const entryPath = join(tempDir, entry);
31
+ if (existsSync(join(entryPath, "bin"))) {
32
+ appDir = entryPath;
33
+ break;
34
+ }
35
+ }
36
+
37
+ const exeName = metadata.name.replace(/ /g, "");
38
+ const binExt = process.platform === "win32" ? ".exe" : "";
39
+ const launcherPath = join(appDir, "bin", `${exeName}${binExt}`);
40
+
41
+ if (!existsSync(launcherPath)) {
42
+ throw new Error(`Launcher not found: ${launcherPath}`);
43
+ }
44
+
45
+ const proc = Bun.spawn([launcherPath], {
46
+ cwd: join(appDir, "bin"),
47
+ stdio: ["inherit", "inherit", "inherit"],
48
+ });
49
+
50
+ const exitCode = await proc.exited;
51
+
52
+ // Clean up temp directory
53
+ try {
54
+ rmSync(tempDir, { recursive: true, force: true });
55
+ } catch {}
56
+
57
+ process.exit(exitCode);
58
+ } catch (e: any) {
59
+ console.error(`Installer failed: ${e.message}`);
60
+ // Clean up on failure
61
+ try {
62
+ rmSync(tempDir, { recursive: true, force: true });
63
+ } catch {}
64
+ if (process.platform === "win32") {
65
+ console.log("Press Enter to exit...");
66
+ for await (const _ of console) { break; }
67
+ }
68
+ process.exit(1);
69
+ }