sparkbun 0.1.7 → 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.7",
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(
@@ -2548,7 +2559,8 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2548
2559
  // libasar is still loaded by SparkBunCore at runtime — needed until
2549
2560
  // native wrappers are recompiled without ASAR support
2550
2561
  const libExt = targetOS === "win" ? ".dll" : targetOS === "macos" ? ".dylib" : ".so";
2551
- const asarLibSource = join(dirname(targetPaths.BSPATCH), "libasar" + libExt);
2562
+ const platformDistDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`);
2563
+ const asarLibSource = join(platformDistDir, "libasar" + libExt);
2552
2564
  if (existsSync(asarLibSource)) {
2553
2565
  cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), {
2554
2566
  dereference: true,
@@ -2813,9 +2825,12 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2813
2825
  artifactsToUpload.push(debPath);
2814
2826
  }
2815
2827
 
2816
- // This branch only runs for non-dev release packaging, so the temp app bundle
2817
- // can always be removed after the tarball is produced.
2818
- rmSync(appBundleFolderPath, { recursive: true });
2828
+ // For Windows/Linux, the app bundle is deleted after tarring since
2829
+ // createCompiledInstaller builds a standalone installer binary.
2830
+ // For macOS, keep the app bundle — it goes directly into the DMG.
2831
+ if (targetOS !== "macos") {
2832
+ rmSync(appBundleFolderPath, { recursive: true });
2833
+ }
2819
2834
 
2820
2835
  // generate bsdiff
2821
2836
  // https://storage.googleapis.com/eggbun-static/sparkbun-playground/canary/SparkBunPlayground-canary.app.tar.gz
@@ -2973,84 +2988,52 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2973
2988
  unlinkSync(tarPath);
2974
2989
  }
2975
2990
 
2976
- const selfExtractingBundle = createAppBundle(
2977
- bundleName,
2978
- buildFolder,
2979
- targetOS,
2980
- );
2981
- const compressedTarballInExtractingBundlePath = join(
2982
- selfExtractingBundle.appBundleFolderResourcesPath,
2983
- `${hash}.tar.gz`,
2984
- );
2985
-
2986
- // copy the gzip tarball to the self-extracting app bundle
2987
- cpSync(compressedTarPath, compressedTarballInExtractingBundlePath, {
2988
- dereference: true,
2989
- });
2990
-
2991
- // macOS uses the Zig extractor in the self-extracting .app bundle.
2992
- // Windows/Linux use createCompiledInstaller instead.
2993
- if (targetOS === "macos") {
2994
- const selfExtractorBinSourcePath = targetPaths.EXTRACTOR;
2995
- const selfExtractorBinDestinationPath = join(
2996
- selfExtractingBundle.appBundleMacOSPath,
2997
- config.app.name.replace(/ /g, ""),
2991
+ // For Windows/Linux, create a self-extracting installer bundle
2992
+ if (targetOS !== "macos") {
2993
+ const selfExtractingBundle = createAppBundle(
2994
+ bundleName,
2995
+ buildFolder,
2996
+ targetOS,
2997
+ );
2998
+ const compressedTarballInExtractingBundlePath = join(
2999
+ selfExtractingBundle.appBundleFolderResourcesPath,
3000
+ `${hash}.tar.gz`,
2998
3001
  );
2999
3002
 
3000
- cpSync(selfExtractorBinSourcePath, selfExtractorBinDestinationPath, {
3003
+ cpSync(compressedTarPath, compressedTarballInExtractingBundlePath, {
3001
3004
  dereference: true,
3002
3005
  });
3003
- }
3004
-
3005
- buildIcons(
3006
- selfExtractingBundle.appBundleFolderResourcesPath,
3007
- selfExtractingBundle.appBundleFolderPath,
3008
- );
3009
- await Bun.write(
3010
- join(selfExtractingBundle.appBundleFolderContentsPath, "Info.plist"),
3011
- InfoPlistContents,
3012
- );
3013
3006
 
3014
- // Write metadata.json to outer bundle (consistent with Windows/Linux)
3015
- const extractorMetadata = {
3016
- identifier: config.app.identifier,
3017
- name: config.app.name,
3018
- channel: buildEnvironment,
3019
- hash: hash,
3020
- };
3021
- await Bun.write(
3022
- join(
3007
+ buildIcons(
3023
3008
  selfExtractingBundle.appBundleFolderResourcesPath,
3024
- "metadata.json",
3025
- ),
3026
- JSON.stringify(extractorMetadata, null, 2),
3027
- );
3028
-
3029
- // Run postWrap hook after self-extracting bundle is created, before code signing
3030
- // This is where you can add files to the wrapper (e.g., for liquid glass support)
3031
- runHook("postWrap", {
3032
- SPARKBUN_WRAPPER_BUNDLE_PATH:
3033
3009
  selfExtractingBundle.appBundleFolderPath,
3034
- });
3010
+ );
3011
+ await Bun.write(
3012
+ join(selfExtractingBundle.appBundleFolderContentsPath, "Info.plist"),
3013
+ InfoPlistContents,
3014
+ );
3035
3015
 
3036
- if (shouldCodesign) {
3037
- codesignAppBundle(
3038
- selfExtractingBundle.appBundleFolderPath,
3039
- join(buildFolder, "entitlements.plist"),
3040
- config,
3016
+ const extractorMetadata = {
3017
+ identifier: config.app.identifier,
3018
+ name: config.app.name,
3019
+ channel: buildEnvironment,
3020
+ hash: hash,
3021
+ };
3022
+ await Bun.write(
3023
+ join(
3024
+ selfExtractingBundle.appBundleFolderResourcesPath,
3025
+ "metadata.json",
3026
+ ),
3027
+ JSON.stringify(extractorMetadata, null, 2),
3041
3028
  );
3042
- } else {
3043
- console.log("skipping codesign");
3044
- }
3045
3029
 
3046
- // Note: we need to notarize the original app bundle, the self-extracting app bundle, and the dmg
3047
- if (shouldNotarize) {
3048
- notarizeAndStaple(selfExtractingBundle.appBundleFolderPath, config);
3049
- } else {
3050
- console.log("skipping notarization");
3030
+ runHook("postWrap", {
3031
+ SPARKBUN_WRAPPER_BUNDLE_PATH:
3032
+ selfExtractingBundle.appBundleFolderPath,
3033
+ });
3051
3034
  }
3052
3035
 
3053
- // DMG creation for macOS only
3036
+ // DMG creation for macOS — uses the real app bundle directly
3054
3037
  if (targetOS === "macos" && config.build.mac?.createDmg !== false) {
3055
3038
  console.log("creating dmg...");
3056
3039
  const finalDmgPath = join(buildFolder, `${appFileName}.dmg`);
@@ -3078,10 +3061,10 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3078
3061
  // Copy the app bundle to the staging directory
3079
3062
  const stagedAppPath = join(
3080
3063
  dmgStagingDir,
3081
- basename(selfExtractingBundle.appBundleFolderPath),
3064
+ basename(appBundleFolderPath),
3082
3065
  );
3083
3066
  execSync(
3084
- `cp -R ${escapePathForTerminal(selfExtractingBundle.appBundleFolderPath)} ${escapePathForTerminal(stagedAppPath)}`,
3067
+ `cp -R ${escapePathForTerminal(appBundleFolderPath)} ${escapePathForTerminal(stagedAppPath)}`,
3085
3068
  );
3086
3069
 
3087
3070
  // Create a symlink to /Applications for easy drag-and-drop installation
@@ -3215,6 +3198,394 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3215
3198
  // stapler validate -v <app path>
3216
3199
  }
3217
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
+
3218
3589
  // Take over as the terminal's foreground process group (macOS/Linux).
3219
3590
  // This prevents the parent bun script runner from receiving SIGINT
3220
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
+ }