sparkbun 0.2.2 → 0.2.3
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
|
@@ -141,6 +141,50 @@ export interface SparkBunConfig {
|
|
|
141
141
|
copy?: {
|
|
142
142
|
[sourcePath: string]: string;
|
|
143
143
|
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Configuration for the `sparkbun installer` command, which builds a
|
|
147
|
+
* single-file graphical installer with embedded payload archives.
|
|
148
|
+
*
|
|
149
|
+
* Each archive is tarred (preserving its source folder name as the
|
|
150
|
+
* archive root) and compressed independently. The compiled installer
|
|
151
|
+
* app reads the embedded `archives.json` manifest and extracts the
|
|
152
|
+
* payloads itself — the framework does not auto-extract, giving the
|
|
153
|
+
* installer full control over the process (streaming, progress,
|
|
154
|
+
* placement, ordering, conditional components, etc.).
|
|
155
|
+
*
|
|
156
|
+
* Archives can also be supplied on the CLI via repeated
|
|
157
|
+
* `--archive name=path` flags; config entries and flags are combined.
|
|
158
|
+
*/
|
|
159
|
+
installer?: {
|
|
160
|
+
archives?: Array<{
|
|
161
|
+
/**
|
|
162
|
+
* Logical identifier for the archive. Referenced by the app and
|
|
163
|
+
* used as the manifest key. Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/.
|
|
164
|
+
*/
|
|
165
|
+
name: string;
|
|
166
|
+
/**
|
|
167
|
+
* Source directory (relative to project root or absolute). The
|
|
168
|
+
* directory's own name becomes the archive's top-level folder,
|
|
169
|
+
* so it extracts into the correct subdirectory.
|
|
170
|
+
*/
|
|
171
|
+
path: string;
|
|
172
|
+
/**
|
|
173
|
+
* Compression algorithm.
|
|
174
|
+
* - "zstd" — best ratio at high levels, streaming decode (default)
|
|
175
|
+
* - "gzip" — widest compatibility, faster, larger output
|
|
176
|
+
* @default "zstd"
|
|
177
|
+
*/
|
|
178
|
+
format?: "zstd" | "gzip";
|
|
179
|
+
/**
|
|
180
|
+
* Compression level. zstd: 1–22 (22 = maximum, large window).
|
|
181
|
+
* gzip: 1–9. Higher is smaller but slower to compress.
|
|
182
|
+
* @default 19 for zstd, 9 for gzip
|
|
183
|
+
*/
|
|
184
|
+
level?: number;
|
|
185
|
+
}>;
|
|
186
|
+
};
|
|
187
|
+
|
|
144
188
|
/**
|
|
145
189
|
* Output folder for built application
|
|
146
190
|
* @default "build"
|
package/src/cli/index.ts
CHANGED
|
@@ -56,7 +56,47 @@ function createTar(tarPath: string, cwd: string, entries: string[]) {
|
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
59
|
+
// Recursively measure a directory's total file bytes and file count.
|
|
60
|
+
// Used to embed accurate progress denominators in the installer archive manifest.
|
|
61
|
+
function measureDir(dir: string): { bytes: number; fileCount: number } {
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
let fileCount = 0;
|
|
64
|
+
const stack = [dir];
|
|
65
|
+
while (stack.length) {
|
|
66
|
+
const d = stack.pop()!;
|
|
67
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
68
|
+
const full = join(d, entry.name);
|
|
69
|
+
if (entry.isDirectory()) stack.push(full);
|
|
70
|
+
else if (entry.isFile()) {
|
|
71
|
+
bytes += statSync(full).size;
|
|
72
|
+
fileCount++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { bytes, fileCount };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stream-compress a file with zstd or gzip at a specific level, using node:zlib
|
|
80
|
+
// transforms so build memory stays bounded regardless of input size. Web
|
|
81
|
+
// CompressionStream("zstd") is intentionally not used — it has no level control,
|
|
82
|
+
// and the high compression ratio depends on level 22's large window.
|
|
83
|
+
async function streamCompressFile(
|
|
84
|
+
srcPath: string,
|
|
85
|
+
destPath: string,
|
|
86
|
+
format: "zstd" | "gzip",
|
|
87
|
+
level: number,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const zlib = require("node:zlib");
|
|
90
|
+
const { createReadStream, createWriteStream } = require("node:fs");
|
|
91
|
+
const { pipeline } = require("node:stream/promises");
|
|
92
|
+
const transform =
|
|
93
|
+
format === "gzip"
|
|
94
|
+
? zlib.createGzip({ level })
|
|
95
|
+
: zlib.createZstdCompress({
|
|
96
|
+
params: { [zlib.constants.ZSTD_c_compressionLevel]: level },
|
|
97
|
+
});
|
|
98
|
+
await pipeline(createReadStream(srcPath), transform, createWriteStream(destPath));
|
|
99
|
+
}
|
|
60
100
|
|
|
61
101
|
// this when run as an npm script this will be where the folder where package.json is.
|
|
62
102
|
const projectRoot = process.cwd();
|
|
@@ -3207,10 +3247,15 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3207
3247
|
* with admin elevation. On second launch (from temp, with DLLs present) it
|
|
3208
3248
|
* loads SparkBunCore and runs the webview-based installer UI.
|
|
3209
3249
|
*
|
|
3210
|
-
* Payload archives are
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3250
|
+
* Payload archives are tarred (preserving each source's folder name as the
|
|
3251
|
+
* archive root) and compressed with zstd (default, levels 1–22) or gzip,
|
|
3252
|
+
* then embedded directly in the binary. They stay embedded until the user
|
|
3253
|
+
* triggers installation; the app code reads the embedded `archives.json`
|
|
3254
|
+
* manifest and extracts them itself — typically by streaming the embedded
|
|
3255
|
+
* blob through DecompressionStream("zstd") into a tar reader, which bounds
|
|
3256
|
+
* memory to the decompression window regardless of archive size. The
|
|
3257
|
+
* framework never auto-extracts payloads, so the installer has full control
|
|
3258
|
+
* over progress, placement, ordering, and conditional components.
|
|
3214
3259
|
*
|
|
3215
3260
|
* This is a separate pipeline from `runBuild` — it does not produce update
|
|
3216
3261
|
* artifacts, delta patches, DMGs, or .deb packages.
|
|
@@ -3221,7 +3266,59 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3221
3266
|
) {
|
|
3222
3267
|
// --- Parse installer-specific CLI flags ---
|
|
3223
3268
|
|
|
3224
|
-
const
|
|
3269
|
+
const getFlag = (prefix: string): string | undefined =>
|
|
3270
|
+
process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
|
|
3271
|
+
|
|
3272
|
+
// Compression defaults for archives supplied via --archive flags.
|
|
3273
|
+
// (Archives declared in sparkbun.config.ts carry their own per-archive
|
|
3274
|
+
// format/level, so each project can mix tight and fast compression.)
|
|
3275
|
+
const globalFormat = ((getFlag("--format=") as "zstd" | "gzip") || "zstd");
|
|
3276
|
+
const globalLevelFlag = getFlag("--level=");
|
|
3277
|
+
const defaultLevelFor = (fmt: "zstd" | "gzip") => (fmt === "gzip" ? 9 : 19);
|
|
3278
|
+
|
|
3279
|
+
type ArchiveSpec = {
|
|
3280
|
+
name: string;
|
|
3281
|
+
path: string;
|
|
3282
|
+
format: "zstd" | "gzip";
|
|
3283
|
+
level: number;
|
|
3284
|
+
};
|
|
3285
|
+
const archives: ArchiveSpec[] = [];
|
|
3286
|
+
|
|
3287
|
+
const addArchive = (
|
|
3288
|
+
name: string,
|
|
3289
|
+
rawPath: string,
|
|
3290
|
+
fmt: "zstd" | "gzip",
|
|
3291
|
+
level: number,
|
|
3292
|
+
) => {
|
|
3293
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3294
|
+
console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
|
|
3295
|
+
process.exit(1);
|
|
3296
|
+
}
|
|
3297
|
+
if (archives.some((a) => a.name === name)) {
|
|
3298
|
+
console.error(`Duplicate archive name: "${name}".`);
|
|
3299
|
+
process.exit(1);
|
|
3300
|
+
}
|
|
3301
|
+
const resolvedPath = path.resolve(projectRoot, rawPath);
|
|
3302
|
+
if (!existsSync(resolvedPath)) {
|
|
3303
|
+
console.error(`Archive path does not exist: ${resolvedPath}`);
|
|
3304
|
+
process.exit(1);
|
|
3305
|
+
}
|
|
3306
|
+
archives.push({ name, path: resolvedPath, format: fmt, level });
|
|
3307
|
+
};
|
|
3308
|
+
|
|
3309
|
+
// Archives declared in sparkbun.config.ts (build.installer.archives)
|
|
3310
|
+
const configArchives = (config.build as any)?.installer?.archives as
|
|
3311
|
+
| Array<{ name: string; path: string; format?: "zstd" | "gzip"; level?: number }>
|
|
3312
|
+
| undefined;
|
|
3313
|
+
if (configArchives) {
|
|
3314
|
+
for (const a of configArchives) {
|
|
3315
|
+
const fmt = a.format || globalFormat;
|
|
3316
|
+
const level = a.level ?? (globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(fmt));
|
|
3317
|
+
addArchive(a.name, a.path, fmt, level);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
// Archives passed via --archive name=path (level/format from global flags)
|
|
3225
3322
|
for (let i = 0; i < process.argv.length; i++) {
|
|
3226
3323
|
if (process.argv[i] === "--archive" && process.argv[i + 1]) {
|
|
3227
3324
|
const arg = process.argv[i + 1]!;
|
|
@@ -3230,28 +3327,11 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3230
3327
|
console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
|
|
3231
3328
|
process.exit(1);
|
|
3232
3329
|
}
|
|
3233
|
-
const
|
|
3234
|
-
|
|
3235
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3236
|
-
console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
|
|
3237
|
-
process.exit(1);
|
|
3238
|
-
}
|
|
3239
|
-
if (archives.some((a) => a.name === name)) {
|
|
3240
|
-
console.error(`Duplicate archive name: "${name}".`);
|
|
3241
|
-
process.exit(1);
|
|
3242
|
-
}
|
|
3243
|
-
const resolvedPath = path.resolve(projectRoot, archivePath);
|
|
3244
|
-
if (!existsSync(resolvedPath)) {
|
|
3245
|
-
console.error(`Archive path does not exist: ${resolvedPath}`);
|
|
3246
|
-
process.exit(1);
|
|
3247
|
-
}
|
|
3248
|
-
archives.push({ name, path: resolvedPath });
|
|
3330
|
+
const level = globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(globalFormat);
|
|
3331
|
+
addArchive(arg.substring(0, eqIdx), arg.substring(eqIdx + 1), globalFormat, level);
|
|
3249
3332
|
}
|
|
3250
3333
|
}
|
|
3251
3334
|
|
|
3252
|
-
const getFlag = (prefix: string): string | undefined =>
|
|
3253
|
-
process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
|
|
3254
|
-
|
|
3255
3335
|
const outPath = getFlag("--out=");
|
|
3256
3336
|
const iconPath = getFlag("--icon=");
|
|
3257
3337
|
const installerName = getFlag("--name=") || config.app.name;
|
|
@@ -3311,46 +3391,56 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3311
3391
|
if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
|
|
3312
3392
|
mkdirSync(stagingDir, { recursive: true });
|
|
3313
3393
|
|
|
3314
|
-
|
|
3394
|
+
type ArchiveInfo = {
|
|
3395
|
+
file: string;
|
|
3396
|
+
format: "zstd" | "gzip";
|
|
3397
|
+
level: number;
|
|
3398
|
+
uncompressedBytes: number;
|
|
3399
|
+
fileCount: number;
|
|
3400
|
+
compressedBytes: number;
|
|
3401
|
+
};
|
|
3402
|
+
const archiveManifest: Record<string, ArchiveInfo> = {};
|
|
3315
3403
|
|
|
3316
3404
|
if (archives.length > 0) {
|
|
3317
|
-
console.log("Creating archives (
|
|
3405
|
+
console.log("Creating archives (tar + streaming compression)...");
|
|
3318
3406
|
|
|
3319
|
-
const
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
throw new Error(`makensis not found at ${makensisPath}. LZMA compression requires NSIS binaries in dist-${targetOS}-${targetARCH}/nsis/`);
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
|
-
for (const { name, path: archiveSrcPath } of archives) {
|
|
3326
|
-
const archiveFileName = `archive-${name}.exe`;
|
|
3407
|
+
for (const spec of archives) {
|
|
3408
|
+
const ext = spec.format === "gzip" ? "tar.gz" : "tar.zst";
|
|
3409
|
+
const archiveFileName = `archive-${spec.name}.${ext}`;
|
|
3327
3410
|
const archiveDestPath = join(stagingDir, archiveFileName);
|
|
3328
|
-
const
|
|
3411
|
+
const tarTmpPath = join(stagingDir, `archive-${spec.name}.tar`);
|
|
3329
3412
|
|
|
3330
|
-
console.log(` Archiving ${name}: ${
|
|
3413
|
+
console.log(` Archiving ${spec.name}: ${spec.path}`);
|
|
3331
3414
|
|
|
3332
|
-
|
|
3333
|
-
|
|
3415
|
+
// Build an uncompressed tar that preserves the source folder name as
|
|
3416
|
+
// the archive root (e.g. "Dune 2000/..."), so the installer extracts
|
|
3417
|
+
// into the correct subdirectory. tar -C <parent> <basename>.
|
|
3418
|
+
const parent = dirname(spec.path);
|
|
3419
|
+
const baseName = basename(spec.path);
|
|
3420
|
+
createTar(tarTmpPath, parent, [baseName]);
|
|
3334
3421
|
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
stdout: "pipe",
|
|
3339
|
-
stderr: "pipe",
|
|
3340
|
-
});
|
|
3422
|
+
// Measure uncompressed bytes + file count so the installer can show
|
|
3423
|
+
// real per-file / byte progress without unpacking first.
|
|
3424
|
+
const { bytes: uncompressedBytes, fileCount } = measureDir(spec.path);
|
|
3341
3425
|
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
console.error(`makensis failed for ${name}:`, stderr || stdout);
|
|
3346
|
-
throw new Error(`Failed to compress archive: ${name}`);
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
|
-
unlinkSync(nsiPath);
|
|
3426
|
+
// Stream-compress the tar at the requested level (bounded build memory).
|
|
3427
|
+
await streamCompressFile(tarTmpPath, archiveDestPath, spec.format, spec.level);
|
|
3428
|
+
unlinkSync(tarTmpPath);
|
|
3350
3429
|
|
|
3351
|
-
const
|
|
3352
|
-
console.log(
|
|
3353
|
-
|
|
3430
|
+
const compressedBytes = statSync(archiveDestPath).size;
|
|
3431
|
+
console.log(
|
|
3432
|
+
` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
|
|
3433
|
+
`${(compressedBytes / 1024 / 1024).toFixed(0)} MB ` +
|
|
3434
|
+
`(${spec.format} level ${spec.level}, ${fileCount} files)`,
|
|
3435
|
+
);
|
|
3436
|
+
archiveManifest[spec.name] = {
|
|
3437
|
+
file: archiveFileName,
|
|
3438
|
+
format: spec.format,
|
|
3439
|
+
level: spec.level,
|
|
3440
|
+
uncompressedBytes,
|
|
3441
|
+
fileCount,
|
|
3442
|
+
compressedBytes,
|
|
3443
|
+
};
|
|
3354
3444
|
}
|
|
3355
3445
|
}
|
|
3356
3446
|
|
|
@@ -3521,7 +3611,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3521
3611
|
const archiveImports: string[] = [];
|
|
3522
3612
|
const archiveExports: string[] = [];
|
|
3523
3613
|
for (const { name } of archives) {
|
|
3524
|
-
const fileName = archiveManifest[name];
|
|
3614
|
+
const fileName = archiveManifest[name]!.file;
|
|
3525
3615
|
archiveImports.push(`import archive_${name} from "./${fileName}" with { type: "file" };`);
|
|
3526
3616
|
archiveExports.push(` "${name}": archive_${name},`);
|
|
3527
3617
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This file is the entrypoint for installers built with `bun sparkbun installer`.
|
|
5
5
|
* It gets compiled into a single executable that contains:
|
|
6
6
|
* - A small runtime bundle (DLLs, views, app code — ~1MB compressed)
|
|
7
|
-
* - The installer's payload archives (
|
|
7
|
+
* - The installer's payload archives (tar + zstd/gzip)
|
|
8
8
|
*
|
|
9
9
|
* The binary uses a two-phase execution model with a single Bun runtime:
|
|
10
10
|
*
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
*
|
|
26
26
|
* The payload archives stay embedded in the binary at all times — they are
|
|
27
27
|
* never extracted to temp. Only when the user clicks "Install" does the app
|
|
28
|
-
* code
|
|
28
|
+
* code read them (via Bun.embeddedFiles, guided by the Resources/archives.json
|
|
29
|
+
* manifest) and stream them to disk.
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
|