sparkbun 0.2.6 → 0.2.8
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 +1 -1
- package/src/bun/SparkBunConfig.ts +23 -3
- package/src/bun/core/Updater.ts +26 -11
- package/src/cli/archive-stream.ts +292 -0
- package/src/cli/index.ts +237 -86
- package/src/installer/installer-template.ts +33 -11
package/package.json
CHANGED
|
@@ -167,6 +167,12 @@ export interface SparkBunConfig {
|
|
|
167
167
|
* Source directory (relative to project root or absolute). The
|
|
168
168
|
* directory's own name becomes the archive's top-level folder,
|
|
169
169
|
* so it extracts into the correct subdirectory.
|
|
170
|
+
*
|
|
171
|
+
* May also point at a PREBUILT `.tar` / `.tar.gz` / `.tar.zst`
|
|
172
|
+
* file, which is embedded verbatim (no per-build tar+compress —
|
|
173
|
+
* useful when a large payload is slow to compress and rarely
|
|
174
|
+
* changes). Format is taken from the extension; `format`/
|
|
175
|
+
* `compression` are ignored for prebuilt archives.
|
|
170
176
|
*/
|
|
171
177
|
path: string;
|
|
172
178
|
/**
|
|
@@ -177,9 +183,23 @@ export interface SparkBunConfig {
|
|
|
177
183
|
*/
|
|
178
184
|
format?: "zstd" | "gzip";
|
|
179
185
|
/**
|
|
180
|
-
* Compression
|
|
181
|
-
*
|
|
182
|
-
*
|
|
186
|
+
* Compression tier.
|
|
187
|
+
* - "basic" — Bun's built-in CompressionStream (zstd ≈ level 3,
|
|
188
|
+
* gzip ≈ level 6). Fast, streaming, no level control. (default)
|
|
189
|
+
* - number — explicit level via node:zlib streaming transforms.
|
|
190
|
+
* zstd: 1–22 (22 = maximum; large window, smallest output).
|
|
191
|
+
* gzip: 1–9. Higher is smaller but slower to compress.
|
|
192
|
+
* - "none" — no compression; a plain `.tar`. For payloads that
|
|
193
|
+
* are already compressed (video, media) where recompression
|
|
194
|
+
* wastes build time. The manifest records format "none" and
|
|
195
|
+
* the app's extractor must skip its decompression step.
|
|
196
|
+
* @default "basic"
|
|
197
|
+
*/
|
|
198
|
+
compression?: "none" | "basic" | number;
|
|
199
|
+
/**
|
|
200
|
+
* Legacy alias for a numeric `compression` tier. Ignored when
|
|
201
|
+
* `compression` is set.
|
|
202
|
+
* @deprecated Use `compression` instead.
|
|
183
203
|
*/
|
|
184
204
|
level?: number;
|
|
185
205
|
}>;
|
package/src/bun/core/Updater.ts
CHANGED
|
@@ -922,17 +922,16 @@ if %rmRetry% GEQ 10 goto rmfailed
|
|
|
922
922
|
timeout /t 2 /nobreak >nul
|
|
923
923
|
goto rmloop
|
|
924
924
|
:rmfailed
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
925
|
+
:: The script runs in a hidden window, so log instead of echo/pause (a hidden
|
|
926
|
+
:: pause would hang invisibly forever).
|
|
927
|
+
echo Update failed: could not remove "${runningAppWin}" after retries. Files may still be locked by a helper process. >> "%~dp0update-error.log"
|
|
928
928
|
exit /b 1
|
|
929
929
|
:rmdone
|
|
930
930
|
|
|
931
931
|
:: Move new app to current location (safe now that destination is gone)
|
|
932
932
|
move "${newAppWin}" "${runningAppWin}"
|
|
933
933
|
if not exist "${launcherPathWin}" (
|
|
934
|
-
echo Update failed: launcher not found at "${launcherPathWin}" after move.
|
|
935
|
-
pause
|
|
934
|
+
echo Update failed: launcher not found at "${launcherPathWin}" after move. >> "%~dp0update-error.log"
|
|
936
935
|
exit /b 1
|
|
937
936
|
)
|
|
938
937
|
|
|
@@ -947,24 +946,40 @@ for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "SparkBunUpdat
|
|
|
947
946
|
schtasks /delete /tn "%%t" /f >nul 2>&1
|
|
948
947
|
)
|
|
949
948
|
|
|
950
|
-
:: Delete this update script after a short delay
|
|
949
|
+
:: Delete this update script (and its hidden-window launcher) after a short delay
|
|
951
950
|
ping -n 2 127.0.0.1 >nul
|
|
951
|
+
del "%~dp0update.vbs" 2>nul
|
|
952
952
|
del "%~f0"
|
|
953
953
|
`;
|
|
954
954
|
|
|
955
955
|
await Bun.write(updateScriptPath, updateScript);
|
|
956
956
|
|
|
957
|
+
// VBS shim so the batch script runs with a HIDDEN window (style 0).
|
|
958
|
+
// schtasks can't hide a console app's window itself, and without
|
|
959
|
+
// this the whole update — wait loop included — sits in a visible
|
|
960
|
+
// cmd window. wscript.exe is a GUI-subsystem host, so nothing
|
|
961
|
+
// flashes. Errors are logged to update-error.log by the bat.
|
|
962
|
+
const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
|
|
963
|
+
const vbsPath = join(parentDir, "update.vbs");
|
|
964
|
+
const vbsPathWin = vbsPath.replace(/\//g, "\\");
|
|
965
|
+
await Bun.write(
|
|
966
|
+
vbsPath,
|
|
967
|
+
`CreateObject("WScript.Shell").Run "cmd /c ""${scriptPathWin}""", 0, False\r\n`,
|
|
968
|
+
);
|
|
969
|
+
|
|
957
970
|
// Use Windows Task Scheduler to run the update script independently
|
|
958
971
|
// This ensures the script runs even after the app exits
|
|
959
|
-
const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
|
|
960
972
|
const taskName = `SparkBunUpdate_${Date.now()}`;
|
|
961
973
|
|
|
962
|
-
// Create a scheduled task that runs immediately and deletes itself
|
|
974
|
+
// Create a scheduled task that runs immediately and deletes itself.
|
|
975
|
+
// windowsHide stops the schtasks invocations themselves from
|
|
976
|
+
// flashing console windows (the app is a GUI-subsystem process,
|
|
977
|
+
// so each unhidden child would allocate a fresh visible console).
|
|
963
978
|
execSync(
|
|
964
|
-
`schtasks /create /tn "${taskName}" /tr "
|
|
965
|
-
{ stdio: "ignore" },
|
|
979
|
+
`schtasks /create /tn "${taskName}" /tr "wscript.exe //B \\"${vbsPathWin}\\"" /sc once /st 00:00 /f`,
|
|
980
|
+
{ stdio: "ignore", windowsHide: true },
|
|
966
981
|
);
|
|
967
|
-
execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore" });
|
|
982
|
+
execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore", windowsHide: true });
|
|
968
983
|
// The task will be cleaned up by Windows after it runs, or we delete it in the batch script
|
|
969
984
|
|
|
970
985
|
// Use quit() for graceful shutdown - this closes all windows and processes
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming, pure-Bun tar + compression for installer payload archives.
|
|
3
|
+
*
|
|
4
|
+
* The tar layer is hand-rolled ustar, emitted entry-by-entry with file bodies
|
|
5
|
+
* read in 8 MiB slices, so build memory stays bounded regardless of payload
|
|
6
|
+
* size — and there is no system `tar` dependency. (Shelling out to tar was a
|
|
7
|
+
* reliability problem: macOS bsdtar emits xattr pax headers that GNU tar
|
|
8
|
+
* rejects, and GNU tar parses "C:\..." as a remote-host spec.) The output is
|
|
9
|
+
* plain ustar — long paths via the name/prefix split, file modes preserved
|
|
10
|
+
* (executables stay executable), symlinks as typeflag '2' — readable by every
|
|
11
|
+
* tar implementation and by apps' streaming extractors.
|
|
12
|
+
*
|
|
13
|
+
* Compression tiers, chosen per archive (config `compression` field or
|
|
14
|
+
* `--compression` flag):
|
|
15
|
+
* - "basic" — Bun's built-in CompressionStream (zstd default ≈ level 3;
|
|
16
|
+
* gzip default ≈ level 6). Fast, streaming, no level control.
|
|
17
|
+
* The default tier.
|
|
18
|
+
* - <number> — explicit level via node:zlib streaming transforms (zstd 1–22,
|
|
19
|
+
* gzip 1–9). zstd 22 uses a large window; its ratio depends on
|
|
20
|
+
* it. Slower to build, smallest output.
|
|
21
|
+
* - "none" — no compression; a plain `.tar`. For payloads that are already
|
|
22
|
+
* compressed (video, archives) where recompression wastes time.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readdirSync, statSync, lstatSync, readlinkSync, createWriteStream } from "node:fs";
|
|
26
|
+
import { pipeline } from "node:stream/promises";
|
|
27
|
+
import zlib from "node:zlib";
|
|
28
|
+
import { join, basename } from "node:path";
|
|
29
|
+
|
|
30
|
+
export type ArchiveFormat = "zstd" | "gzip";
|
|
31
|
+
export type ArchiveCompression = "none" | "basic" | number;
|
|
32
|
+
|
|
33
|
+
const READ_CHUNK = 8 * 1024 * 1024; // file read slice size (bounds memory)
|
|
34
|
+
|
|
35
|
+
/** File extension for an archive of the given format + compression. */
|
|
36
|
+
export function archiveExtension(format: ArchiveFormat, compression: ArchiveCompression): string {
|
|
37
|
+
if (compression === "none") return "tar";
|
|
38
|
+
return format === "gzip" ? "tar.gz" : "tar.zst";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The effective numeric level recorded in the manifest: 0 for "none", the
|
|
43
|
+
* library default for "basic" (zstd 3 / gzip 6 — approximate; CompressionStream
|
|
44
|
+
* exposes no level), or the explicit number.
|
|
45
|
+
*/
|
|
46
|
+
export function effectiveLevel(format: ArchiveFormat, compression: ArchiveCompression): number {
|
|
47
|
+
if (compression === "none") return 0;
|
|
48
|
+
if (compression === "basic") return format === "gzip" ? 6 : 3;
|
|
49
|
+
return compression;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Human label for build logs, e.g. "zstd level 22", "zstd (basic)", "uncompressed". */
|
|
53
|
+
export function compressionLabel(format: ArchiveFormat, compression: ArchiveCompression): string {
|
|
54
|
+
if (compression === "none") return "uncompressed";
|
|
55
|
+
if (compression === "basic") return `${format} (basic)`;
|
|
56
|
+
return `${format} level ${compression}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A POSIX ustar 512-byte header. Splits long paths across name/prefix. */
|
|
60
|
+
function ustarHeader(
|
|
61
|
+
path: string,
|
|
62
|
+
size: number,
|
|
63
|
+
type: "0" | "2" | "5",
|
|
64
|
+
mode: number,
|
|
65
|
+
linkname = "",
|
|
66
|
+
): Uint8Array {
|
|
67
|
+
const bl = (s: string) => Buffer.byteLength(s);
|
|
68
|
+
let name = path;
|
|
69
|
+
let prefix = "";
|
|
70
|
+
if (bl(name) > 100) {
|
|
71
|
+
// Split at a "/" so the suffix fits the 100-byte name field and the
|
|
72
|
+
// prefix fits the 155-byte prefix field (extractors rejoin them).
|
|
73
|
+
let split = -1;
|
|
74
|
+
for (let i = 0; i < path.length; i++) {
|
|
75
|
+
if (path[i] !== "/") continue;
|
|
76
|
+
if (bl(path.slice(0, i)) <= 155 && bl(path.slice(i + 1)) <= 100) {
|
|
77
|
+
split = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (split < 0) throw new Error(`Path too long for ustar: ${path}`);
|
|
82
|
+
prefix = path.slice(0, split);
|
|
83
|
+
name = path.slice(split + 1);
|
|
84
|
+
}
|
|
85
|
+
if (bl(linkname) > 100) throw new Error(`Symlink target too long for ustar: ${linkname}`);
|
|
86
|
+
const h = Buffer.alloc(512);
|
|
87
|
+
h.write(name, 0);
|
|
88
|
+
const putOct = (off: number, len: number, v: number) =>
|
|
89
|
+
h.write(v.toString(8).padStart(len - 1, "0") + "\0", off);
|
|
90
|
+
putOct(100, 8, mode & 0o7777);
|
|
91
|
+
putOct(108, 8, 0); // uid
|
|
92
|
+
putOct(116, 8, 0); // gid
|
|
93
|
+
putOct(124, 12, size);
|
|
94
|
+
putOct(136, 12, 0); // mtime
|
|
95
|
+
h.write(type, 156);
|
|
96
|
+
h.write(linkname, 157);
|
|
97
|
+
h.write("ustar\0", 257);
|
|
98
|
+
h.write("00", 263);
|
|
99
|
+
h.write(prefix, 345);
|
|
100
|
+
h.fill(0x20, 148, 156); // checksum field = spaces while summing
|
|
101
|
+
let sum = 0;
|
|
102
|
+
for (let i = 0; i < 512; i++) sum += h[i]!;
|
|
103
|
+
h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
|
|
104
|
+
return new Uint8Array(h.buffer, h.byteOffset, h.length);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type WalkEntry = {
|
|
108
|
+
abs: string;
|
|
109
|
+
rel: string;
|
|
110
|
+
kind: "dir" | "file" | "symlink";
|
|
111
|
+
size: number;
|
|
112
|
+
mode: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Depth-first walk; directories before their contents; "/" separators. */
|
|
116
|
+
function* walk(absDir: string, rel: string): Generator<WalkEntry> {
|
|
117
|
+
const entries = readdirSync(absDir, { withFileTypes: true }).sort((x, y) =>
|
|
118
|
+
x.name.localeCompare(y.name),
|
|
119
|
+
);
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (e.name === ".DS_Store") continue;
|
|
122
|
+
const abs = join(absDir, e.name);
|
|
123
|
+
const r = `${rel}/${e.name}`;
|
|
124
|
+
if (e.isSymbolicLink()) {
|
|
125
|
+
yield { abs, rel: r, kind: "symlink", size: 0, mode: 0o777 };
|
|
126
|
+
} else if (e.isDirectory()) {
|
|
127
|
+
yield { abs, rel: r, kind: "dir", size: 0, mode: lstatSync(abs).mode };
|
|
128
|
+
yield* walk(abs, r);
|
|
129
|
+
} else if (e.isFile()) {
|
|
130
|
+
const st = statSync(abs);
|
|
131
|
+
yield { abs, rel: r, kind: "file", size: st.size, mode: st.mode };
|
|
132
|
+
}
|
|
133
|
+
// Sockets/FIFOs/devices are skipped.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The tar bytes for the tree rooted at `srcDir`, as a lazy async sequence.
|
|
139
|
+
* The directory's own name becomes the archive root.
|
|
140
|
+
*/
|
|
141
|
+
export async function* tarBytes(srcDir: string): AsyncGenerator<Uint8Array> {
|
|
142
|
+
const rootName = basename(srcDir);
|
|
143
|
+
yield ustarHeader(`${rootName}/`, 0, "5", statSync(srcDir).mode);
|
|
144
|
+
for (const e of walk(srcDir, rootName)) {
|
|
145
|
+
if (e.kind === "dir") {
|
|
146
|
+
yield ustarHeader(`${e.rel}/`, 0, "5", e.mode);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (e.kind === "symlink") {
|
|
150
|
+
yield ustarHeader(e.rel, 0, "2", e.mode, readlinkSync(e.abs));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
yield ustarHeader(e.rel, e.size, "0", e.mode);
|
|
154
|
+
const f = Bun.file(e.abs);
|
|
155
|
+
for (let off = 0; off < e.size; off += READ_CHUNK) {
|
|
156
|
+
yield new Uint8Array(await f.slice(off, Math.min(off + READ_CHUNK, e.size)).arrayBuffer());
|
|
157
|
+
}
|
|
158
|
+
const pad = (512 - (e.size % 512)) % 512;
|
|
159
|
+
if (pad) yield new Uint8Array(pad);
|
|
160
|
+
}
|
|
161
|
+
yield new Uint8Array(1024); // end-of-archive: two zero blocks
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Scan a prebuilt tar archive (optionally zstd/gzip compressed) and total its
|
|
166
|
+
* file payload — the uncompressedBytes/fileCount progress denominators the
|
|
167
|
+
* manifest needs. Streaming: headers are parsed and bodies skipped, so memory
|
|
168
|
+
* stays bounded.
|
|
169
|
+
*/
|
|
170
|
+
export async function scanTarArchive(
|
|
171
|
+
path: string,
|
|
172
|
+
format: ArchiveFormat | "none",
|
|
173
|
+
): Promise<{ uncompressedBytes: number; fileCount: number }> {
|
|
174
|
+
let stream = Bun.file(path).stream() as unknown as ReadableStream<Uint8Array>;
|
|
175
|
+
if (format !== "none") {
|
|
176
|
+
const ds = new DecompressionStream(
|
|
177
|
+
(format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
|
|
178
|
+
) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
179
|
+
stream = stream.pipeThrough(ds);
|
|
180
|
+
}
|
|
181
|
+
const reader = stream.getReader();
|
|
182
|
+
const td = new TextDecoder();
|
|
183
|
+
let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
184
|
+
let uncompressedBytes = 0;
|
|
185
|
+
let fileCount = 0;
|
|
186
|
+
let skip = 0; // bytes of body+padding left to discard
|
|
187
|
+
while (true) {
|
|
188
|
+
const { done, value } = await reader.read();
|
|
189
|
+
if (done) break;
|
|
190
|
+
if (buf.length === 0) buf = value;
|
|
191
|
+
else {
|
|
192
|
+
const merged = new Uint8Array(buf.length + value.length);
|
|
193
|
+
merged.set(buf, 0);
|
|
194
|
+
merged.set(value, buf.length);
|
|
195
|
+
buf = merged;
|
|
196
|
+
}
|
|
197
|
+
while (true) {
|
|
198
|
+
if (skip > 0) {
|
|
199
|
+
const n = Math.min(skip, buf.length);
|
|
200
|
+
buf = buf.subarray(n);
|
|
201
|
+
skip -= n;
|
|
202
|
+
if (skip > 0) break;
|
|
203
|
+
}
|
|
204
|
+
if (buf.length < 512) break;
|
|
205
|
+
const h = buf.subarray(0, 512);
|
|
206
|
+
buf = buf.subarray(512);
|
|
207
|
+
let allZero = true;
|
|
208
|
+
for (let i = 0; i < 512; i++) {
|
|
209
|
+
if (h[i] !== 0) { allZero = false; break; }
|
|
210
|
+
}
|
|
211
|
+
if (allZero) continue; // end-of-archive marker block
|
|
212
|
+
const size =
|
|
213
|
+
parseInt(td.decode(h.subarray(124, 136)).replace(/\0.*$/, "").trim() || "0", 8) || 0;
|
|
214
|
+
const typeflag = String.fromCharCode(h[156] || 0);
|
|
215
|
+
// Regular files count toward the payload; metadata entries (long-name
|
|
216
|
+
// 'L'/'K', pax 'x'/'g') and dirs/links don't, but their bodies are
|
|
217
|
+
// still skipped.
|
|
218
|
+
if (typeflag === "0" || typeflag === "\0") {
|
|
219
|
+
uncompressedBytes += size;
|
|
220
|
+
fileCount++;
|
|
221
|
+
}
|
|
222
|
+
skip = size + ((512 - (size % 512)) % 512);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { uncompressedBytes, fileCount };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Wrap an async generator as a web ReadableStream (for CompressionStream). */
|
|
229
|
+
function toReadable(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
230
|
+
return new ReadableStream<Uint8Array>({
|
|
231
|
+
async pull(c) {
|
|
232
|
+
const { done, value } = await gen.next();
|
|
233
|
+
if (done) c.close();
|
|
234
|
+
else c.enqueue(value);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Tar `srcDir` and write it to `destPath`, compressed per the tier. Streaming
|
|
241
|
+
* end to end: nothing close to the payload size is ever in memory.
|
|
242
|
+
*/
|
|
243
|
+
export async function writeTarArchive(
|
|
244
|
+
srcDir: string,
|
|
245
|
+
destPath: string,
|
|
246
|
+
format: ArchiveFormat,
|
|
247
|
+
compression: ArchiveCompression,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const tar = tarBytes(srcDir);
|
|
250
|
+
|
|
251
|
+
if (compression === "none") {
|
|
252
|
+
await pipeline(tar, createWriteStream(destPath));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (compression === "basic") {
|
|
257
|
+
// Bun's built-in CompressionStream. "zstd" is valid in Bun but missing
|
|
258
|
+
// from the lib.dom CompressionFormat union (and the stream generics
|
|
259
|
+
// predate Uint8Array<ArrayBuffer>), hence the casts.
|
|
260
|
+
const cs = new CompressionStream(
|
|
261
|
+
(format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
|
|
262
|
+
) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
263
|
+
const compressed = toReadable(tar).pipeThrough(cs);
|
|
264
|
+
const sink = Bun.file(destPath).writer();
|
|
265
|
+
const reader = compressed.getReader();
|
|
266
|
+
try {
|
|
267
|
+
while (true) {
|
|
268
|
+
const { done, value } = await reader.read();
|
|
269
|
+
if (done) break;
|
|
270
|
+
sink.write(value);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
await sink.end();
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Explicit level via node:zlib streaming transforms. The zstd APIs exist in
|
|
279
|
+
// Bun's node:zlib but not yet in the bundled @types, hence the cast.
|
|
280
|
+
const z = zlib as unknown as {
|
|
281
|
+
createGzip(opts: { level: number }): NodeJS.ReadWriteStream;
|
|
282
|
+
createZstdCompress(opts: { params: Record<number, number> }): NodeJS.ReadWriteStream;
|
|
283
|
+
constants: { ZSTD_c_compressionLevel: number };
|
|
284
|
+
};
|
|
285
|
+
const transform =
|
|
286
|
+
format === "gzip"
|
|
287
|
+
? z.createGzip({ level: compression })
|
|
288
|
+
: z.createZstdCompress({
|
|
289
|
+
params: { [z.constants.ZSTD_c_compressionLevel]: compression },
|
|
290
|
+
});
|
|
291
|
+
await pipeline(tar, transform, createWriteStream(destPath));
|
|
292
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,15 @@ import {
|
|
|
33
33
|
getMacOSBundleDisplayName,
|
|
34
34
|
} from "../shared/naming";
|
|
35
35
|
import { getTemplate, getTemplateNames } from "./templates/embedded";
|
|
36
|
+
import {
|
|
37
|
+
writeTarArchive,
|
|
38
|
+
scanTarArchive,
|
|
39
|
+
archiveExtension,
|
|
40
|
+
effectiveLevel,
|
|
41
|
+
compressionLabel,
|
|
42
|
+
type ArchiveFormat,
|
|
43
|
+
type ArchiveCompression,
|
|
44
|
+
} from "./archive-stream";
|
|
36
45
|
// MacOS named pipes hang at around 4KB
|
|
37
46
|
// @ts-expect-error - reserved for future use
|
|
38
47
|
const _MAX_CHUNK_SIZE = 1024 * 2;
|
|
@@ -64,6 +73,38 @@ function createTar(tarPath: string, cwd: string, entries: string[]) {
|
|
|
64
73
|
);
|
|
65
74
|
}
|
|
66
75
|
|
|
76
|
+
// Compile an executable via Bun.build({ compile }) with a retry around a known
|
|
77
|
+
// transient failure: on Windows the compile stamps PE version metadata through
|
|
78
|
+
// resource updates (EndUpdateResourceW), and antivirus/indexer scans of the
|
|
79
|
+
// freshly written exe intermittently fail that commit with "Failed to set
|
|
80
|
+
// Windows metadata: FailedToCommit". Clear any stale output first (a scanned
|
|
81
|
+
// leftover exe can hold locks), then retry a few times before giving up.
|
|
82
|
+
async function buildCompiledExeWithRetry(
|
|
83
|
+
options: Parameters<typeof Bun.build>[0],
|
|
84
|
+
outputPath: string,
|
|
85
|
+
): Promise<Awaited<ReturnType<typeof Bun.build>>> {
|
|
86
|
+
try { rmSync(outputPath, { force: true }); } catch {}
|
|
87
|
+
for (let attempt = 1; ; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
return await Bun.build(options);
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
// Bun surfaces compile errors as BuildMessage-like objects whose
|
|
92
|
+
// .message can be empty — the text only shows through Bun's inspect
|
|
93
|
+
// formatting, so match against every representation we can get.
|
|
94
|
+
let msg = "";
|
|
95
|
+
try {
|
|
96
|
+
msg = `${err?.message ?? ""} ${String(err)} ${Bun.inspect(err)}`;
|
|
97
|
+
} catch {}
|
|
98
|
+
if (attempt < 4 && /FailedToCommit|Failed to set Windows metadata/i.test(msg)) {
|
|
99
|
+
console.log(` PE metadata commit failed (attempt ${attempt}/4) — retrying in 2s...`);
|
|
100
|
+
await Bun.sleep(2000);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
67
108
|
// Recursively measure a directory's total file bytes and file count.
|
|
68
109
|
// Used to embed accurate progress denominators in the installer archive manifest.
|
|
69
110
|
function measureDir(dir: string): { bytes: number; fileCount: number } {
|
|
@@ -84,28 +125,6 @@ function measureDir(dir: string): { bytes: number; fileCount: number } {
|
|
|
84
125
|
return { bytes, fileCount };
|
|
85
126
|
}
|
|
86
127
|
|
|
87
|
-
// Stream-compress a file with zstd or gzip at a specific level, using node:zlib
|
|
88
|
-
// transforms so build memory stays bounded regardless of input size. Web
|
|
89
|
-
// CompressionStream("zstd") is intentionally not used — it has no level control,
|
|
90
|
-
// and the high compression ratio depends on level 22's large window.
|
|
91
|
-
async function streamCompressFile(
|
|
92
|
-
srcPath: string,
|
|
93
|
-
destPath: string,
|
|
94
|
-
format: "zstd" | "gzip",
|
|
95
|
-
level: number,
|
|
96
|
-
): Promise<void> {
|
|
97
|
-
const zlib = require("node:zlib");
|
|
98
|
-
const { createReadStream, createWriteStream } = require("node:fs");
|
|
99
|
-
const { pipeline } = require("node:stream/promises");
|
|
100
|
-
const transform =
|
|
101
|
-
format === "gzip"
|
|
102
|
-
? zlib.createGzip({ level })
|
|
103
|
-
: zlib.createZstdCompress({
|
|
104
|
-
params: { [zlib.constants.ZSTD_c_compressionLevel]: level },
|
|
105
|
-
});
|
|
106
|
-
await pipeline(createReadStream(srcPath), transform, createWriteStream(destPath));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
128
|
// this when run as an npm script this will be where the folder where package.json is.
|
|
110
129
|
const projectRoot = process.cwd();
|
|
111
130
|
|
|
@@ -1313,6 +1332,30 @@ function escapeXml(str: string): string {
|
|
|
1313
1332
|
.replace(/'/g, "'");
|
|
1314
1333
|
}
|
|
1315
1334
|
|
|
1335
|
+
/**
|
|
1336
|
+
* Flip the PE subsystem from CONSOLE (3) to WINDOWS GUI (2) so the exe never
|
|
1337
|
+
* opens a console window. Byte-identical to `editbin /SUBSYSTEM:WINDOWS`.
|
|
1338
|
+
*
|
|
1339
|
+
* This exists because Bun's `compile.windows.hideConsole` is a silent no-op:
|
|
1340
|
+
* the only code that applies it sits after a switch in which every prong
|
|
1341
|
+
* returns early (oven-sh/bun#19916; fix PR oven-sh/bun#20338 unmerged as of
|
|
1342
|
+
* Bun 1.3.14). Bun's intended implementation writes the exact same byte at the
|
|
1343
|
+
* exact same offset, so when upstream merges the fix, `hideConsole: true`
|
|
1344
|
+
* takes over and this patch degrades to a no-op (the `current !== 2` check).
|
|
1345
|
+
*
|
|
1346
|
+
* Unlike Bun's flag, this also works when cross-compiling from macOS/Linux
|
|
1347
|
+
* (Bun only applies windows options when the build host is Windows).
|
|
1348
|
+
*
|
|
1349
|
+
* Must run AFTER all other PE edits (payload injection, rescle metadata) and
|
|
1350
|
+
* BEFORE any future code signing — mutating a signed exe invalidates the
|
|
1351
|
+
* signature.
|
|
1352
|
+
*
|
|
1353
|
+
* Historical note: a May 2026 commit (cf58be7) blamed hideConsole for breaking
|
|
1354
|
+
* the ShellExecuteW("runas") elevation flow. Since hideConsole never touched
|
|
1355
|
+
* the binary, that breakage came from elsewhere (likely the experimental
|
|
1356
|
+
* signature-stripping variant of this function that was being tested at the
|
|
1357
|
+
* same time and was reverted in the same commit).
|
|
1358
|
+
*/
|
|
1316
1359
|
function patchPeSubsystem(exePath: string): void {
|
|
1317
1360
|
const buf = readFileSync(exePath);
|
|
1318
1361
|
const peOffset = buf.readUInt32LE(0x3c);
|
|
@@ -2144,8 +2187,9 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
2144
2187
|
if (targetOS === "win") {
|
|
2145
2188
|
if (OS !== "win") {
|
|
2146
2189
|
console.warn(
|
|
2147
|
-
`\n⚠️ Cross-compiling for Windows: icon
|
|
2190
|
+
`\n⚠️ Cross-compiling for Windows: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
|
|
2148
2191
|
` Bun's Windows-specific compile options require building on a Windows host.\n` +
|
|
2192
|
+
` (The console window is still hidden — the PE subsystem patch runs on any host.)\n` +
|
|
2149
2193
|
` See: https://bun.com/docs/bundler/executables#windows-specific-flags\n`
|
|
2150
2194
|
);
|
|
2151
2195
|
} else {
|
|
@@ -3177,6 +3221,8 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3177
3221
|
hash,
|
|
3178
3222
|
config,
|
|
3179
3223
|
projectRoot,
|
|
3224
|
+
targetOS,
|
|
3225
|
+
currentTarget.arch,
|
|
3180
3226
|
);
|
|
3181
3227
|
artifactsToUpload.push(installerPath);
|
|
3182
3228
|
}
|
|
@@ -3199,8 +3245,10 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3199
3245
|
// the download button or display on your marketing site or in the app.
|
|
3200
3246
|
version: config.app.version,
|
|
3201
3247
|
hash: hash.toString(),
|
|
3202
|
-
platform
|
|
3203
|
-
|
|
3248
|
+
// The TARGET platform of these artifacts (host != target when
|
|
3249
|
+
// cross-compiling, e.g. --target=win-x64 on macOS).
|
|
3250
|
+
platform: targetOS,
|
|
3251
|
+
arch: currentTarget.arch,
|
|
3204
3252
|
// channel: buildEnvironment,
|
|
3205
3253
|
// baseUrl: config.release.baseUrl
|
|
3206
3254
|
});
|
|
@@ -3284,24 +3332,53 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3284
3332
|
|
|
3285
3333
|
// Compression defaults for archives supplied via --archive flags.
|
|
3286
3334
|
// (Archives declared in sparkbun.config.ts carry their own per-archive
|
|
3287
|
-
// format/
|
|
3288
|
-
const globalFormat = ((getFlag("--format=") as
|
|
3335
|
+
// format/compression, so each project can mix tight and fast compression.)
|
|
3336
|
+
const globalFormat = ((getFlag("--format=") as ArchiveFormat) || "zstd");
|
|
3289
3337
|
const globalLevelFlag = getFlag("--level=");
|
|
3290
|
-
const
|
|
3338
|
+
const globalCompressionFlag = getFlag("--compression=");
|
|
3339
|
+
|
|
3340
|
+
// Parse a compression tier: "none" | "basic" | numeric level. Validates
|
|
3341
|
+
// the level range for the format (zstd 1–22, gzip 1–9).
|
|
3342
|
+
const parseCompression = (
|
|
3343
|
+
raw: string | number,
|
|
3344
|
+
fmt: ArchiveFormat,
|
|
3345
|
+
source: string,
|
|
3346
|
+
): ArchiveCompression => {
|
|
3347
|
+
if (raw === "none" || raw === "basic") return raw;
|
|
3348
|
+
const level = Number(raw);
|
|
3349
|
+
const max = fmt === "gzip" ? 9 : 22;
|
|
3350
|
+
if (!Number.isInteger(level) || level < 1 || level > max) {
|
|
3351
|
+
console.error(
|
|
3352
|
+
`Invalid compression for ${source}: "${raw}". Expected "none", "basic", or a ${fmt} level 1-${max}.`,
|
|
3353
|
+
);
|
|
3354
|
+
process.exit(1);
|
|
3355
|
+
}
|
|
3356
|
+
return level;
|
|
3357
|
+
};
|
|
3358
|
+
|
|
3359
|
+
// Compression tier for archives that don't specify their own: the
|
|
3360
|
+
// --compression / legacy --level flags, else "basic" (Bun's built-in
|
|
3361
|
+
// CompressionStream — fast, streaming, no level control).
|
|
3362
|
+
const defaultCompressionFor = (fmt: ArchiveFormat, source: string): ArchiveCompression =>
|
|
3363
|
+
globalCompressionFlag
|
|
3364
|
+
? parseCompression(globalCompressionFlag, fmt, source)
|
|
3365
|
+
: globalLevelFlag
|
|
3366
|
+
? parseCompression(globalLevelFlag, fmt, source)
|
|
3367
|
+
: "basic";
|
|
3291
3368
|
|
|
3292
3369
|
type ArchiveSpec = {
|
|
3293
3370
|
name: string;
|
|
3294
3371
|
path: string;
|
|
3295
|
-
format:
|
|
3296
|
-
|
|
3372
|
+
format: ArchiveFormat;
|
|
3373
|
+
compression: ArchiveCompression;
|
|
3297
3374
|
};
|
|
3298
3375
|
const archives: ArchiveSpec[] = [];
|
|
3299
3376
|
|
|
3300
3377
|
const addArchive = (
|
|
3301
3378
|
name: string,
|
|
3302
3379
|
rawPath: string,
|
|
3303
|
-
fmt:
|
|
3304
|
-
|
|
3380
|
+
fmt: ArchiveFormat,
|
|
3381
|
+
compression: ArchiveCompression,
|
|
3305
3382
|
) => {
|
|
3306
3383
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3307
3384
|
console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
|
|
@@ -3316,22 +3393,35 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3316
3393
|
console.error(`Archive path does not exist: ${resolvedPath}`);
|
|
3317
3394
|
process.exit(1);
|
|
3318
3395
|
}
|
|
3319
|
-
archives.push({ name, path: resolvedPath, format: fmt,
|
|
3396
|
+
archives.push({ name, path: resolvedPath, format: fmt, compression });
|
|
3320
3397
|
};
|
|
3321
3398
|
|
|
3322
|
-
// Archives declared in sparkbun.config.ts (build.installer.archives)
|
|
3399
|
+
// Archives declared in sparkbun.config.ts (build.installer.archives).
|
|
3400
|
+
// `compression` ("none" | "basic" | level) is the preferred knob; the
|
|
3401
|
+
// legacy `level` field still works as a numeric tier.
|
|
3323
3402
|
const configArchives = (config.build as any)?.installer?.archives as
|
|
3324
|
-
| Array<{
|
|
3403
|
+
| Array<{
|
|
3404
|
+
name: string;
|
|
3405
|
+
path: string;
|
|
3406
|
+
format?: ArchiveFormat;
|
|
3407
|
+
compression?: "none" | "basic" | number;
|
|
3408
|
+
level?: number;
|
|
3409
|
+
}>
|
|
3325
3410
|
| undefined;
|
|
3326
3411
|
if (configArchives) {
|
|
3327
3412
|
for (const a of configArchives) {
|
|
3328
3413
|
const fmt = a.format || globalFormat;
|
|
3329
|
-
const
|
|
3330
|
-
|
|
3414
|
+
const compression =
|
|
3415
|
+
a.compression !== undefined
|
|
3416
|
+
? parseCompression(a.compression, fmt, `archive "${a.name}"`)
|
|
3417
|
+
: a.level !== undefined
|
|
3418
|
+
? parseCompression(a.level, fmt, `archive "${a.name}"`)
|
|
3419
|
+
: defaultCompressionFor(fmt, `archive "${a.name}"`);
|
|
3420
|
+
addArchive(a.name, a.path, fmt, compression);
|
|
3331
3421
|
}
|
|
3332
3422
|
}
|
|
3333
3423
|
|
|
3334
|
-
// Archives passed via --archive name=path (
|
|
3424
|
+
// Archives passed via --archive name=path (format/compression from global flags)
|
|
3335
3425
|
for (let i = 0; i < process.argv.length; i++) {
|
|
3336
3426
|
if (process.argv[i] === "--archive" && process.argv[i + 1]) {
|
|
3337
3427
|
const arg = process.argv[i + 1]!;
|
|
@@ -3340,8 +3430,13 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3340
3430
|
console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
|
|
3341
3431
|
process.exit(1);
|
|
3342
3432
|
}
|
|
3343
|
-
const
|
|
3344
|
-
addArchive(
|
|
3433
|
+
const name = arg.substring(0, eqIdx);
|
|
3434
|
+
addArchive(
|
|
3435
|
+
name,
|
|
3436
|
+
arg.substring(eqIdx + 1),
|
|
3437
|
+
globalFormat,
|
|
3438
|
+
defaultCompressionFor(globalFormat, `--archive ${name}`),
|
|
3439
|
+
);
|
|
3345
3440
|
}
|
|
3346
3441
|
}
|
|
3347
3442
|
|
|
@@ -3409,7 +3504,13 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3409
3504
|
|
|
3410
3505
|
type ArchiveInfo = {
|
|
3411
3506
|
file: string;
|
|
3412
|
-
|
|
3507
|
+
// "none" = plain .tar, no decompression step at extract time.
|
|
3508
|
+
format: "zstd" | "gzip" | "none";
|
|
3509
|
+
// The tier as configured ("prebuilt" = the config pointed at an
|
|
3510
|
+
// already-built archive file, embedded verbatim); `level` keeps the
|
|
3511
|
+
// effective numeric level for older consumers (0 = uncompressed or
|
|
3512
|
+
// unknown; "basic" ≈ zstd 3 / gzip 6).
|
|
3513
|
+
compression: "none" | "basic" | number | "prebuilt";
|
|
3413
3514
|
level: number;
|
|
3414
3515
|
uncompressedBytes: number;
|
|
3415
3516
|
fileCount: number;
|
|
@@ -3418,51 +3519,74 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3418
3519
|
const archiveManifest: Record<string, ArchiveInfo> = {};
|
|
3419
3520
|
|
|
3420
3521
|
if (archives.length > 0) {
|
|
3421
|
-
console.log("Creating archives (tar +
|
|
3522
|
+
console.log("Creating archives (streaming tar + compression)...");
|
|
3422
3523
|
|
|
3423
3524
|
for (const spec of archives) {
|
|
3424
|
-
|
|
3525
|
+
// A FILE path is a prebuilt archive: embed it verbatim instead of
|
|
3526
|
+
// tarring + compressing a directory. Lets projects with large,
|
|
3527
|
+
// slow-to-compress payloads (e.g. zstd-22 game data) build them
|
|
3528
|
+
// once and skip the per-build recompression entirely. Format comes
|
|
3529
|
+
// from the extension; the manifest's progress denominators come
|
|
3530
|
+
// from a streaming scan of the archive.
|
|
3531
|
+
if (statSync(spec.path).isFile()) {
|
|
3532
|
+
const m = spec.path.match(/\.tar(\.zst|\.gz)?$/i);
|
|
3533
|
+
if (!m) {
|
|
3534
|
+
console.error(
|
|
3535
|
+
`Archive "${spec.name}": file paths must point at a prebuilt .tar/.tar.gz/.tar.zst (got ${spec.path})`,
|
|
3536
|
+
);
|
|
3537
|
+
process.exit(1);
|
|
3538
|
+
}
|
|
3539
|
+
const fmt: "zstd" | "gzip" | "none" =
|
|
3540
|
+
m[1]?.toLowerCase() === ".zst" ? "zstd" : m[1]?.toLowerCase() === ".gz" ? "gzip" : "none";
|
|
3541
|
+
const preExt = fmt === "none" ? "tar" : fmt === "gzip" ? "tar.gz" : "tar.zst";
|
|
3542
|
+
const preFileName = `archive-${spec.name}.${preExt}`;
|
|
3543
|
+
console.log(` Embedding prebuilt ${spec.name}: ${spec.path}`);
|
|
3544
|
+
cpSync(spec.path, join(stagingDir, preFileName));
|
|
3545
|
+
const { uncompressedBytes, fileCount } = await scanTarArchive(spec.path, fmt);
|
|
3546
|
+
const compressedBytes = statSync(spec.path).size;
|
|
3547
|
+
console.log(
|
|
3548
|
+
` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
|
|
3549
|
+
`${(compressedBytes / 1024 / 1024).toFixed(0)} MB (prebuilt ${preExt}, ${fileCount} files)`,
|
|
3550
|
+
);
|
|
3551
|
+
archiveManifest[spec.name] = {
|
|
3552
|
+
file: preFileName,
|
|
3553
|
+
format: fmt,
|
|
3554
|
+
compression: "prebuilt",
|
|
3555
|
+
level: 0,
|
|
3556
|
+
uncompressedBytes,
|
|
3557
|
+
fileCount,
|
|
3558
|
+
compressedBytes,
|
|
3559
|
+
};
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
const ext = archiveExtension(spec.format, spec.compression);
|
|
3425
3564
|
const archiveFileName = `archive-${spec.name}.${ext}`;
|
|
3426
3565
|
const archiveDestPath = join(stagingDir, archiveFileName);
|
|
3427
|
-
const tarTmpPath = join(stagingDir, `archive-${spec.name}.tar`);
|
|
3428
3566
|
|
|
3429
3567
|
console.log(` Archiving ${spec.name}: ${spec.path}`);
|
|
3430
3568
|
|
|
3431
|
-
//
|
|
3432
|
-
//
|
|
3433
|
-
//
|
|
3434
|
-
//
|
|
3435
|
-
//
|
|
3436
|
-
|
|
3437
|
-
// The on-disk format may be GNU (Windows/Linux — long paths via
|
|
3438
|
-
// "././@LongLink") or pax (macOS bsdtar — long paths via extended
|
|
3439
|
-
// headers). The installer's streaming extractor understands both, plus
|
|
3440
|
-
// plain ustar, so the archive is portable regardless of build host.
|
|
3441
|
-
//
|
|
3442
|
-
// (Bun.Archive is intentionally not used here: its lazy Bun.file()
|
|
3443
|
-
// inputs write empty file bodies, and eager byte inputs would require
|
|
3444
|
-
// loading the entire payload into memory.)
|
|
3445
|
-
const parent = dirname(spec.path);
|
|
3446
|
-
const baseName = basename(spec.path);
|
|
3447
|
-
createTar(tarTmpPath, parent, [baseName]);
|
|
3569
|
+
// Hand-rolled streaming tar piped straight into the compressor (see
|
|
3570
|
+
// archive-stream.ts): plain ustar with the source folder name kept
|
|
3571
|
+
// as the archive root (e.g. "Dune 2000/..."), file modes preserved,
|
|
3572
|
+
// bounded build memory, and no system tar dependency — the on-disk
|
|
3573
|
+
// format is identical regardless of build host.
|
|
3574
|
+
await writeTarArchive(spec.path, archiveDestPath, spec.format, spec.compression);
|
|
3448
3575
|
|
|
3449
3576
|
// Measure uncompressed bytes + file count for progress denominators.
|
|
3450
3577
|
const { bytes: uncompressedBytes, fileCount } = measureDir(spec.path);
|
|
3451
3578
|
|
|
3452
|
-
// Stream-compress the tar at the requested level (bounded build memory).
|
|
3453
|
-
await streamCompressFile(tarTmpPath, archiveDestPath, spec.format, spec.level);
|
|
3454
|
-
unlinkSync(tarTmpPath);
|
|
3455
|
-
|
|
3456
3579
|
const compressedBytes = statSync(archiveDestPath).size;
|
|
3457
3580
|
console.log(
|
|
3458
3581
|
` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
|
|
3459
3582
|
`${(compressedBytes / 1024 / 1024).toFixed(0)} MB ` +
|
|
3460
|
-
`(${spec.format
|
|
3583
|
+
`(${compressionLabel(spec.format, spec.compression)}, ${fileCount} files)`,
|
|
3461
3584
|
);
|
|
3462
3585
|
archiveManifest[spec.name] = {
|
|
3463
3586
|
file: archiveFileName,
|
|
3464
|
-
format: spec.format,
|
|
3465
|
-
|
|
3587
|
+
format: spec.compression === "none" ? "none" : spec.format,
|
|
3588
|
+
compression: spec.compression,
|
|
3589
|
+
level: effectiveLevel(spec.format, spec.compression),
|
|
3466
3590
|
uncompressedBytes,
|
|
3467
3591
|
fileCount,
|
|
3468
3592
|
compressedBytes,
|
|
@@ -3659,6 +3783,8 @@ ${archiveExports.join("\n")}
|
|
|
3659
3783
|
|
|
3660
3784
|
console.log("Compiling installer executable...");
|
|
3661
3785
|
|
|
3786
|
+
// (Compiled via buildCompiledExeWithRetry below — PE metadata commit
|
|
3787
|
+
// failures from AV scans are transient and retried.)
|
|
3662
3788
|
const installerCompileOptions: any = {
|
|
3663
3789
|
target: `bun-${targetOSName}-${targetARCH}`,
|
|
3664
3790
|
outfile: outputPath,
|
|
@@ -3681,9 +3807,14 @@ ${archiveExports.join("\n")}
|
|
|
3681
3807
|
}
|
|
3682
3808
|
}
|
|
3683
3809
|
installerCompileOptions.windows = {
|
|
3684
|
-
//
|
|
3685
|
-
//
|
|
3686
|
-
//
|
|
3810
|
+
// Currently a no-op in Bun (oven-sh/bun#19916 — the subsystem edit is
|
|
3811
|
+
// unreachable code; fix PR #20338 unmerged as of 1.3.14), so the
|
|
3812
|
+
// console is actually hidden by patchPeSubsystem() below. Set it
|
|
3813
|
+
// anyway: it's harmless today and becomes the primary mechanism the
|
|
3814
|
+
// day Bun fixes it (the patch then no-ops). See patchPeSubsystem's
|
|
3815
|
+
// doc comment for why the old "conflicts with elevation" story here
|
|
3816
|
+
// was a misattribution.
|
|
3817
|
+
hideConsole: true,
|
|
3687
3818
|
...(icoPath && { icon: icoPath }),
|
|
3688
3819
|
title: `${installerName} Setup`,
|
|
3689
3820
|
version: installerVersion,
|
|
@@ -3693,10 +3824,13 @@ ${archiveExports.join("\n")}
|
|
|
3693
3824
|
};
|
|
3694
3825
|
}
|
|
3695
3826
|
|
|
3696
|
-
const installerBuild = await
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3827
|
+
const installerBuild = await buildCompiledExeWithRetry(
|
|
3828
|
+
{
|
|
3829
|
+
entrypoints: [join(stagingDir, "installer.ts")],
|
|
3830
|
+
compile: installerCompileOptions,
|
|
3831
|
+
},
|
|
3832
|
+
outputPath,
|
|
3833
|
+
);
|
|
3700
3834
|
if (!installerBuild.success) {
|
|
3701
3835
|
console.error("Installer compilation failed:", installerBuild.logs);
|
|
3702
3836
|
throw new Error("Installer compilation failed");
|
|
@@ -4318,9 +4452,16 @@ ${archiveExports.join("\n")}
|
|
|
4318
4452
|
hash: string,
|
|
4319
4453
|
config: any,
|
|
4320
4454
|
projectRoot: string,
|
|
4455
|
+
// The TARGET platform being built for — not the build host. These used to
|
|
4456
|
+
// be derived from the host OS/ARCH globals, so cross-compiling (e.g.
|
|
4457
|
+
// `--target=win-x64` on macOS) wrongly built a host-platform installer
|
|
4458
|
+
// whose extensionless outfile then collided with the app bundle directory
|
|
4459
|
+
// ("is a directory" build failure).
|
|
4460
|
+
targetOS: "win" | "linux" | "macos",
|
|
4461
|
+
targetArch: string,
|
|
4321
4462
|
): Promise<string> {
|
|
4322
|
-
const targetOSName =
|
|
4323
|
-
const isWindows =
|
|
4463
|
+
const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
|
|
4464
|
+
const isWindows = targetOS === "win";
|
|
4324
4465
|
|
|
4325
4466
|
const setupFileName = isWindows
|
|
4326
4467
|
? getWindowsSetupFileName(config.app.name, buildEnvironment)
|
|
@@ -4337,9 +4478,9 @@ ${archiveExports.join("\n")}
|
|
|
4337
4478
|
// Copy archive
|
|
4338
4479
|
copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
|
|
4339
4480
|
|
|
4340
|
-
// Write metadata
|
|
4341
|
-
const platformConfig =
|
|
4342
|
-
:
|
|
4481
|
+
// Write metadata (platform config of the TARGET, not the build host)
|
|
4482
|
+
const platformConfig = targetOS === "macos" ? config.build?.mac
|
|
4483
|
+
: targetOS === "win" ? config.build?.win
|
|
4343
4484
|
: config.build?.linux;
|
|
4344
4485
|
const metadata = {
|
|
4345
4486
|
identifier: config.app.identifier,
|
|
@@ -4356,11 +4497,18 @@ ${archiveExports.join("\n")}
|
|
|
4356
4497
|
copyFileSync(templatePath, join(stagingDir, "installer.ts"));
|
|
4357
4498
|
|
|
4358
4499
|
const installerCompileOptions: any = {
|
|
4359
|
-
target: `bun-${targetOSName}-${
|
|
4500
|
+
target: `bun-${targetOSName}-${targetArch}`,
|
|
4360
4501
|
outfile: outputPath,
|
|
4361
4502
|
};
|
|
4362
4503
|
|
|
4363
|
-
if (isWindows) {
|
|
4504
|
+
if (isWindows && OS !== "win") {
|
|
4505
|
+
// Bun only applies windows compile options on a Windows host; the
|
|
4506
|
+
// console is still hidden by patchPeSubsystem below (works anywhere).
|
|
4507
|
+
console.warn(
|
|
4508
|
+
`\n⚠️ Cross-compiling the Windows installer: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
|
|
4509
|
+
` Build on a Windows host if the installer exe needs them.\n`
|
|
4510
|
+
);
|
|
4511
|
+
} else if (isWindows) {
|
|
4364
4512
|
let icoPath: string | undefined;
|
|
4365
4513
|
if (config.build.win?.icon) {
|
|
4366
4514
|
const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
|
|
@@ -4389,10 +4537,13 @@ ${archiveExports.join("\n")}
|
|
|
4389
4537
|
}
|
|
4390
4538
|
|
|
4391
4539
|
console.log("Compiling installer...");
|
|
4392
|
-
const installerBuild = await
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4540
|
+
const installerBuild = await buildCompiledExeWithRetry(
|
|
4541
|
+
{
|
|
4542
|
+
entrypoints: [join(stagingDir, "installer.ts")],
|
|
4543
|
+
compile: installerCompileOptions,
|
|
4544
|
+
},
|
|
4545
|
+
outputPath,
|
|
4546
|
+
);
|
|
4396
4547
|
if (!installerBuild.success) {
|
|
4397
4548
|
console.error("Installer compilation failed:", installerBuild.logs);
|
|
4398
4549
|
throw new Error("Installer compilation failed");
|
|
@@ -92,6 +92,36 @@ function getInstallDir(meta: Metadata): string {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Create a .lnk via WScript.Shell. windowsHide is essential: the installer is a
|
|
96
|
+
// GUI-subsystem exe, so without it each PowerShell child allocates its own
|
|
97
|
+
// visible console window mid-install. PowerShell is called by absolute path
|
|
98
|
+
// (PATH isn't reliable in every launch context), and the paths are passed
|
|
99
|
+
// through the environment ($env:SB_*) rather than interpolated into the script
|
|
100
|
+
// text, so an app name containing quotes can't break or inject into the command.
|
|
101
|
+
function createShortcut(lnkPath: string, targetPath: string, workingDir: string) {
|
|
102
|
+
const systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
|
|
103
|
+
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
104
|
+
const script =
|
|
105
|
+
"$ws = New-Object -ComObject WScript.Shell;" +
|
|
106
|
+
" $s = $ws.CreateShortcut($env:SB_LNK);" +
|
|
107
|
+
" $s.TargetPath = $env:SB_TARGET;" +
|
|
108
|
+
" $s.WorkingDirectory = $env:SB_WORKDIR;" +
|
|
109
|
+
" $s.Save()";
|
|
110
|
+
try {
|
|
111
|
+
Bun.spawnSync([powershell, "-NoProfile", "-Command", script], {
|
|
112
|
+
stdout: "ignore",
|
|
113
|
+
stderr: "ignore",
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
SB_LNK: lnkPath,
|
|
118
|
+
SB_TARGET: targetPath,
|
|
119
|
+
SB_WORKDIR: workingDir,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
async function createWindowsShortcuts(appDir: string, meta: Metadata) {
|
|
96
126
|
const binDir = join(appDir, "bin");
|
|
97
127
|
const exePath = join(binDir, `${meta.name.replace(/ /g, "")}.exe`);
|
|
@@ -100,19 +130,11 @@ async function createWindowsShortcuts(appDir: string, meta: Metadata) {
|
|
|
100
130
|
|
|
101
131
|
// Start Menu shortcut
|
|
102
132
|
const startMenu = join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs");
|
|
103
|
-
|
|
104
|
-
Bun.spawnSync(["powershell", "-NoProfile", "-Command",
|
|
105
|
-
`$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('${join(startMenu, meta.name + ".lnk")}'); $s.TargetPath = '${exePath}'; $s.WorkingDirectory = '${binDir}'; $s.Save()`
|
|
106
|
-
]);
|
|
107
|
-
} catch {}
|
|
133
|
+
createShortcut(join(startMenu, meta.name + ".lnk"), exePath, binDir);
|
|
108
134
|
|
|
109
135
|
// Desktop shortcut
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Bun.spawnSync(["powershell", "-NoProfile", "-Command",
|
|
113
|
-
`$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('${join(desktop, meta.name + ".lnk")}'); $s.TargetPath = '${exePath}'; $s.WorkingDirectory = '${binDir}'; $s.Save()`
|
|
114
|
-
]);
|
|
115
|
-
} catch {}
|
|
136
|
+
const desktop = join(process.env.USERPROFILE || "", "Desktop");
|
|
137
|
+
createShortcut(join(desktop, meta.name + ".lnk"), exePath, binDir);
|
|
116
138
|
|
|
117
139
|
console.log("Created Start Menu and Desktop shortcuts");
|
|
118
140
|
}
|