@quickgui/cli 0.0.1
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/README.md +72 -0
- package/package.json +50 -0
- package/src/args.ts +182 -0
- package/src/build.ts +773 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +256 -0
- package/src/dev.ts +279 -0
- package/src/error.ts +17 -0
- package/src/index.ts +9 -0
- package/src/init.ts +107 -0
- package/src/targets.ts +85 -0
- package/templates/solid/README.md +8 -0
- package/templates/solid/gitignore +3 -0
- package/templates/solid/package.json +19 -0
- package/templates/solid/quickgui.config.ts +7 -0
- package/templates/solid/src/app.tsx +49 -0
- package/templates/solid/tsconfig.json +16 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { parseCliArgs, type ParsedCliCommand } from "./args.ts";
|
|
6
|
+
import { buildProject } from "./build.ts";
|
|
7
|
+
import { loadConfig } from "./config.ts";
|
|
8
|
+
import { runDev } from "./dev.ts";
|
|
9
|
+
import { CliError, errorMessage } from "./error.ts";
|
|
10
|
+
import { initProject } from "./init.ts";
|
|
11
|
+
import { hostTarget } from "./targets.ts";
|
|
12
|
+
|
|
13
|
+
export const CLI_VERSION = "0.0.1";
|
|
14
|
+
|
|
15
|
+
export async function runCli(argv: string[]): Promise<number> {
|
|
16
|
+
const command = parseCliArgs(argv);
|
|
17
|
+
switch (command.command) {
|
|
18
|
+
case "help":
|
|
19
|
+
console.log(helpText(command.topic));
|
|
20
|
+
return 0;
|
|
21
|
+
case "version":
|
|
22
|
+
console.log(CLI_VERSION);
|
|
23
|
+
return 0;
|
|
24
|
+
case "init": {
|
|
25
|
+
const destination = await initProject(command);
|
|
26
|
+
console.log(`\nCreated QuickGUI project at ${destination}`);
|
|
27
|
+
console.log(`\n cd ${relativeDisplayPath(destination)}`);
|
|
28
|
+
if (!command.install) console.log(" bun install");
|
|
29
|
+
console.log(" bun run dev");
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
case "dev":
|
|
33
|
+
return await runDev(command);
|
|
34
|
+
case "build":
|
|
35
|
+
return await runBuild(command);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function runBuild(
|
|
40
|
+
command: Extract<ParsedCliCommand, { command: "build" }>,
|
|
41
|
+
): Promise<number> {
|
|
42
|
+
const projectRoot = resolve(command.project);
|
|
43
|
+
const config = await loadConfig(projectRoot, command.configFile);
|
|
44
|
+
const target = command.target ?? config.target ?? hostTarget();
|
|
45
|
+
console.log(`[quickgui] Building ${config.name} for ${target}`);
|
|
46
|
+
const result = await buildProject(config, {
|
|
47
|
+
mode: "production",
|
|
48
|
+
target,
|
|
49
|
+
...(command.outDir ? { outDir: command.outDir } : {}),
|
|
50
|
+
...(command.signingIdentity ? { signingIdentity: command.signingIdentity } : {}),
|
|
51
|
+
...(command.notarizationProfile
|
|
52
|
+
? { notarization: { keychainProfile: command.notarizationProfile } }
|
|
53
|
+
: {}),
|
|
54
|
+
});
|
|
55
|
+
console.log(`[quickgui] Created ${result.artifactPath}`);
|
|
56
|
+
if (result.dmgPath) console.log(`[quickgui] Created ${result.dmgPath}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function helpText(topic?: "init" | "dev" | "build"): string {
|
|
61
|
+
if (topic === "init") {
|
|
62
|
+
return `Usage: quickgui init [directory] [options]
|
|
63
|
+
|
|
64
|
+
Create a Solid-powered QuickGUI project.
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--name <name> Application display name
|
|
68
|
+
--identifier <id> Reverse-DNS bundle identifier
|
|
69
|
+
--no-install Do not run bun install
|
|
70
|
+
-h, --help Show this help`;
|
|
71
|
+
}
|
|
72
|
+
if (topic === "dev") {
|
|
73
|
+
return `Usage: quickgui dev [options]
|
|
74
|
+
|
|
75
|
+
Package a native development host, run it, and restart it on source changes.
|
|
76
|
+
On macOS the host is a signed .app; application TS/TSX stays outside the bundle.
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
--project <directory> Project directory (default: .)
|
|
80
|
+
--config <file> Config file (default: quickgui.config.ts)
|
|
81
|
+
--target <target> Host target override
|
|
82
|
+
--sign <identity> macOS signing identity (default: ad-hoc)
|
|
83
|
+
--once Run without watching
|
|
84
|
+
--no-launch Only create the development app
|
|
85
|
+
-h, --help Show this help`;
|
|
86
|
+
}
|
|
87
|
+
if (topic === "build") {
|
|
88
|
+
return `Usage: quickgui build [options]
|
|
89
|
+
|
|
90
|
+
Build a self-contained production application for a target platform.
|
|
91
|
+
|
|
92
|
+
Options:
|
|
93
|
+
--project <directory> Project directory (default: .)
|
|
94
|
+
--config <file> Config file (default: quickgui.config.ts)
|
|
95
|
+
--target <target> darwin-arm64, darwin-x64, linux-arm64,
|
|
96
|
+
linux-x64, windows-arm64, or windows-x64
|
|
97
|
+
--out-dir <directory> Output directory override
|
|
98
|
+
--sign <identity> macOS signing identity (default: ad-hoc)
|
|
99
|
+
--notarize <profile> Notary Keychain profile for the macOS DMG
|
|
100
|
+
-h, --help Show this help`;
|
|
101
|
+
}
|
|
102
|
+
return `QuickGUI CLI ${CLI_VERSION}
|
|
103
|
+
|
|
104
|
+
Usage: quickgui <command> [options]
|
|
105
|
+
|
|
106
|
+
Commands:
|
|
107
|
+
init [directory] Create a Solid-powered project
|
|
108
|
+
dev Run a native app with source reload
|
|
109
|
+
build Package a production application
|
|
110
|
+
|
|
111
|
+
Run \`quickgui help <command>\` for command-specific help.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function relativeDisplayPath(path: string): string {
|
|
115
|
+
const current = process.cwd();
|
|
116
|
+
return path.startsWith(`${current}/`) ? path.slice(current.length + 1) : path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (import.meta.main) {
|
|
120
|
+
try {
|
|
121
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`quickgui: ${errorMessage(error)}`);
|
|
124
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./error.ts";
|
|
6
|
+
import { parseTarget, type QuickGuiTarget } from "./targets.ts";
|
|
7
|
+
|
|
8
|
+
export type { QuickGuiTarget } from "./targets.ts";
|
|
9
|
+
|
|
10
|
+
export interface MacOSNotarizationConfig {
|
|
11
|
+
/** Profile created with `xcrun notarytool store-credentials`. */
|
|
12
|
+
keychainProfile: string;
|
|
13
|
+
/** Optional non-default Keychain containing the profile. */
|
|
14
|
+
keychain?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MacOSConfig {
|
|
18
|
+
minimumSystemVersion?: string;
|
|
19
|
+
category?: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
signingIdentity?: string;
|
|
22
|
+
entitlements?: string;
|
|
23
|
+
/** Mounted disk image title. `create-dmg` limits this to 27 characters. */
|
|
24
|
+
dmgTitle?: string;
|
|
25
|
+
/** Submit the production DMG to Apple's notary service and staple its ticket. */
|
|
26
|
+
notarization?: MacOSNotarizationConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WindowsConfig {
|
|
30
|
+
icon?: string;
|
|
31
|
+
publisher?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
copyright?: string;
|
|
34
|
+
hideConsole?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QuickGuiConfig {
|
|
38
|
+
name: string;
|
|
39
|
+
identifier: string;
|
|
40
|
+
version?: string;
|
|
41
|
+
buildVersion?: string;
|
|
42
|
+
entry?: string;
|
|
43
|
+
outDir?: string;
|
|
44
|
+
target?: QuickGuiTarget;
|
|
45
|
+
resources?: string[];
|
|
46
|
+
/** OpenType font files embedded in the executable and registered before app startup. */
|
|
47
|
+
fonts?: string[];
|
|
48
|
+
/** Custom URL schemes. Packaged macOS apps declare these in their signed Info.plist. */
|
|
49
|
+
protocols?: string[];
|
|
50
|
+
macos?: MacOSConfig;
|
|
51
|
+
windows?: WindowsConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResolvedQuickGuiConfig {
|
|
55
|
+
name: string;
|
|
56
|
+
executableName: string;
|
|
57
|
+
identifier: string;
|
|
58
|
+
version: string;
|
|
59
|
+
buildVersion: string;
|
|
60
|
+
entry: string;
|
|
61
|
+
outDir: string;
|
|
62
|
+
target?: QuickGuiTarget;
|
|
63
|
+
resources: string[];
|
|
64
|
+
fonts: string[];
|
|
65
|
+
protocols: string[];
|
|
66
|
+
macos: Required<Pick<MacOSConfig, "minimumSystemVersion" | "category">> & MacOSConfig;
|
|
67
|
+
windows: Required<Pick<WindowsConfig, "hideConsole">> & WindowsConfig;
|
|
68
|
+
projectRoot: string;
|
|
69
|
+
configPath: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function defineConfig(config: QuickGuiConfig): QuickGuiConfig {
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadConfig(
|
|
77
|
+
projectRoot: string,
|
|
78
|
+
configFile = "quickgui.config.ts",
|
|
79
|
+
): Promise<ResolvedQuickGuiConfig> {
|
|
80
|
+
const root = resolve(projectRoot);
|
|
81
|
+
const configPath = isAbsolute(configFile) ? configFile : resolve(root, configFile);
|
|
82
|
+
if (!existsSync(configPath)) {
|
|
83
|
+
throw new CliError(`QuickGUI config not found: ${configPath}`);
|
|
84
|
+
}
|
|
85
|
+
const url = pathToFileURL(configPath);
|
|
86
|
+
url.searchParams.set("quickgui_reload", `${Date.now()}_${Math.random()}`);
|
|
87
|
+
let module: { default?: unknown };
|
|
88
|
+
try {
|
|
89
|
+
module = (await import(url.href)) as { default?: unknown };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new CliError(`Could not load ${configPath}`, { cause: error });
|
|
92
|
+
}
|
|
93
|
+
return resolveConfig(module.default, root, configPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function resolveConfig(
|
|
97
|
+
input: unknown,
|
|
98
|
+
projectRoot: string,
|
|
99
|
+
configPath = resolve(projectRoot, "quickgui.config.ts"),
|
|
100
|
+
): ResolvedQuickGuiConfig {
|
|
101
|
+
if (!isRecord(input)) throw new CliError("QuickGUI config must export an object");
|
|
102
|
+
const name = requiredString(input.name, "name", 128);
|
|
103
|
+
const identifier = requiredString(input.identifier, "identifier", 255);
|
|
104
|
+
if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(identifier)) {
|
|
105
|
+
throw new CliError(`Invalid application identifier \`${identifier}\``);
|
|
106
|
+
}
|
|
107
|
+
const version = optionalString(input.version, "version", 64) ?? "0.1.0";
|
|
108
|
+
const buildVersion = optionalString(input.buildVersion, "buildVersion", 64) ?? version;
|
|
109
|
+
const entry = resolveRelative(projectRoot, optionalString(input.entry, "entry", 1_024) ?? "src/app.tsx");
|
|
110
|
+
const outDir = resolveRelative(projectRoot, optionalString(input.outDir, "outDir", 1_024) ?? "dist");
|
|
111
|
+
const target = input.target === undefined ? undefined : parseTarget(requiredString(input.target, "target", 64));
|
|
112
|
+
const resources = stringArray(input.resources, "resources").map((path) =>
|
|
113
|
+
resolveRelative(projectRoot, path),
|
|
114
|
+
);
|
|
115
|
+
const fonts = stringArray(input.fonts, "fonts").map((path) =>
|
|
116
|
+
resolveRelative(projectRoot, path),
|
|
117
|
+
);
|
|
118
|
+
const protocols = protocolArray(input.protocols);
|
|
119
|
+
const macos = objectOrEmpty(input.macos, "macos");
|
|
120
|
+
const windows = objectOrEmpty(input.windows, "windows");
|
|
121
|
+
const icon = optionalString(macos.icon, "macos.icon", 1_024);
|
|
122
|
+
const entitlements = optionalString(macos.entitlements, "macos.entitlements", 1_024);
|
|
123
|
+
const notarization = resolveMacOSNotarization(macos.notarization, projectRoot);
|
|
124
|
+
const windowsIcon = optionalString(windows.icon, "windows.icon", 1_024);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
executableName: executableName(name),
|
|
129
|
+
identifier,
|
|
130
|
+
version,
|
|
131
|
+
buildVersion,
|
|
132
|
+
entry,
|
|
133
|
+
outDir,
|
|
134
|
+
...(target ? { target } : {}),
|
|
135
|
+
resources,
|
|
136
|
+
fonts,
|
|
137
|
+
protocols,
|
|
138
|
+
macos: {
|
|
139
|
+
minimumSystemVersion:
|
|
140
|
+
optionalString(macos.minimumSystemVersion, "macos.minimumSystemVersion", 32) ?? "13.0",
|
|
141
|
+
category:
|
|
142
|
+
optionalString(macos.category, "macos.category", 255) ??
|
|
143
|
+
"public.app-category.developer-tools",
|
|
144
|
+
...(icon ? { icon: resolveRelative(projectRoot, icon) } : {}),
|
|
145
|
+
...(optionalString(macos.signingIdentity, "macos.signingIdentity", 512)
|
|
146
|
+
? { signingIdentity: String(macos.signingIdentity) }
|
|
147
|
+
: {}),
|
|
148
|
+
...(entitlements ? { entitlements: resolveRelative(projectRoot, entitlements) } : {}),
|
|
149
|
+
...(optionalString(macos.dmgTitle, "macos.dmgTitle", 27)
|
|
150
|
+
? { dmgTitle: String(macos.dmgTitle) }
|
|
151
|
+
: {}),
|
|
152
|
+
...(notarization ? { notarization } : {}),
|
|
153
|
+
},
|
|
154
|
+
windows: {
|
|
155
|
+
hideConsole: optionalBoolean(windows.hideConsole, "windows.hideConsole") ?? true,
|
|
156
|
+
...(windowsIcon ? { icon: resolveRelative(projectRoot, windowsIcon) } : {}),
|
|
157
|
+
...(optionalString(windows.publisher, "windows.publisher", 255)
|
|
158
|
+
? { publisher: String(windows.publisher) }
|
|
159
|
+
: {}),
|
|
160
|
+
...(optionalString(windows.description, "windows.description", 512)
|
|
161
|
+
? { description: String(windows.description) }
|
|
162
|
+
: {}),
|
|
163
|
+
...(optionalString(windows.copyright, "windows.copyright", 512)
|
|
164
|
+
? { copyright: String(windows.copyright) }
|
|
165
|
+
: {}),
|
|
166
|
+
},
|
|
167
|
+
projectRoot: resolve(projectRoot),
|
|
168
|
+
configPath: resolve(configPath),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveMacOSNotarization(
|
|
173
|
+
value: unknown,
|
|
174
|
+
projectRoot: string,
|
|
175
|
+
): MacOSNotarizationConfig | undefined {
|
|
176
|
+
if (value === undefined) return undefined;
|
|
177
|
+
const notarization = objectOrEmpty(value, "macos.notarization");
|
|
178
|
+
const keychainProfile = requiredString(
|
|
179
|
+
notarization.keychainProfile,
|
|
180
|
+
"macos.notarization.keychainProfile",
|
|
181
|
+
512,
|
|
182
|
+
);
|
|
183
|
+
const keychain = optionalString(notarization.keychain, "macos.notarization.keychain", 1_024);
|
|
184
|
+
return {
|
|
185
|
+
keychainProfile,
|
|
186
|
+
...(keychain ? { keychain: resolveRelative(projectRoot, keychain) } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function protocolArray(value: unknown): string[] {
|
|
191
|
+
if (value === undefined) return [];
|
|
192
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
193
|
+
throw new CliError("`protocols` must be an array with at most 64 URL schemes");
|
|
194
|
+
}
|
|
195
|
+
const protocols = value.map((item, index) =>
|
|
196
|
+
requiredString(item, `protocols[${index}]`, 64).toLowerCase(),
|
|
197
|
+
);
|
|
198
|
+
for (const protocol of protocols) {
|
|
199
|
+
if (!/^[a-z][a-z0-9+.-]*$/.test(protocol)) {
|
|
200
|
+
throw new CliError(`Invalid URL scheme \`${protocol}\` in \`protocols\``);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return [...new Set(protocols)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function executableName(name: string): string {
|
|
207
|
+
const value = name
|
|
208
|
+
.normalize("NFKD")
|
|
209
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
210
|
+
.replace(/^[._-]+|[._-]+$/g, "")
|
|
211
|
+
.slice(0, 128);
|
|
212
|
+
if (!value || value === "." || value === "..") {
|
|
213
|
+
throw new CliError("Application name does not contain a usable executable name");
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveRelative(root: string, path: string): string {
|
|
219
|
+
return isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function objectOrEmpty(value: unknown, field: string): Record<string, unknown> {
|
|
227
|
+
if (value === undefined) return {};
|
|
228
|
+
if (!isRecord(value)) throw new CliError(`\`${field}\` must be an object`);
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function requiredString(value: unknown, field: string, maximum = 255): string {
|
|
233
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > maximum) {
|
|
234
|
+
throw new CliError(`\`${field}\` must be a non-empty string of at most ${maximum} characters`);
|
|
235
|
+
}
|
|
236
|
+
return value.trim();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function optionalString(value: unknown, field: string, maximum: number): string | undefined {
|
|
240
|
+
if (value === undefined) return undefined;
|
|
241
|
+
return requiredString(value, field, maximum);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
245
|
+
if (value === undefined) return undefined;
|
|
246
|
+
if (typeof value !== "boolean") throw new CliError(`\`${field}\` must be a boolean`);
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function stringArray(value: unknown, field: string): string[] {
|
|
251
|
+
if (value === undefined) return [];
|
|
252
|
+
if (!Array.isArray(value) || value.length > 1_024) {
|
|
253
|
+
throw new CliError(`\`${field}\` must be an array with at most 1024 paths`);
|
|
254
|
+
}
|
|
255
|
+
return value.map((item, index) => requiredString(item, `${field}[${index}]`, 1_024));
|
|
256
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { buildProject, type BuildResult } from "./build.ts";
|
|
5
|
+
import { loadConfig, type ResolvedQuickGuiConfig } from "./config.ts";
|
|
6
|
+
import { CliError, errorMessage } from "./error.ts";
|
|
7
|
+
import { hostTarget, type QuickGuiTarget } from "./targets.ts";
|
|
8
|
+
|
|
9
|
+
export interface DevOptions {
|
|
10
|
+
project: string;
|
|
11
|
+
configFile: string;
|
|
12
|
+
once: boolean;
|
|
13
|
+
launch: boolean;
|
|
14
|
+
target?: QuickGuiTarget;
|
|
15
|
+
signingIdentity?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type AppProcess = Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
19
|
+
|
|
20
|
+
interface ExitingProcess {
|
|
21
|
+
readonly exited: Promise<number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal */
|
|
25
|
+
export class ActiveProcessMonitor<T extends ExitingProcess> {
|
|
26
|
+
#active: T | undefined;
|
|
27
|
+
#closed = false;
|
|
28
|
+
#onExit: (status: number) => void;
|
|
29
|
+
|
|
30
|
+
constructor(onExit: (status: number) => void) {
|
|
31
|
+
this.#onExit = onExit;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get active(): T | undefined {
|
|
35
|
+
return this.#active;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
activate(child: T): void {
|
|
39
|
+
this.#active = child;
|
|
40
|
+
void child.exited.then((status) => {
|
|
41
|
+
if (!this.#closed && this.#active === child) this.#onExit(status);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
close(): void {
|
|
46
|
+
this.#closed = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runDev(options: DevOptions): Promise<number> {
|
|
51
|
+
const projectRoot = resolve(options.project);
|
|
52
|
+
const target = options.target ?? hostTarget();
|
|
53
|
+
const host = hostTarget();
|
|
54
|
+
if (target !== host) {
|
|
55
|
+
throw new CliError(
|
|
56
|
+
`Development apps must run on the host target (${host}); use \`quickgui build --target ${target}\` for cross-compilation`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let config = await loadConfig(projectRoot, options.configFile);
|
|
61
|
+
let build = await packageDevelopmentHost(config, target, options.signingIdentity);
|
|
62
|
+
console.log(`[quickgui] Development app: ${build.artifactPath}`);
|
|
63
|
+
if (!options.launch) return 0;
|
|
64
|
+
|
|
65
|
+
if (options.once) {
|
|
66
|
+
const child = await launchApplication(build, config);
|
|
67
|
+
console.log(`[quickgui] App ready (pid ${child.pid})`);
|
|
68
|
+
return await child.exited;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const abortController = new AbortController();
|
|
72
|
+
let stopping = false;
|
|
73
|
+
const processes = new ActiveProcessMonitor<AppProcess>((status) => {
|
|
74
|
+
if (stopping || abortController.signal.aborted) return;
|
|
75
|
+
console.log(`[quickgui] App exited (status ${status}); stopping watcher`);
|
|
76
|
+
abortController.abort();
|
|
77
|
+
});
|
|
78
|
+
let reloadQueued = false;
|
|
79
|
+
let reloadPromise: Promise<void> | undefined;
|
|
80
|
+
let changedPath: string | undefined;
|
|
81
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
|
|
83
|
+
const reload = async (): Promise<void> => {
|
|
84
|
+
try {
|
|
85
|
+
const nextConfig = await loadConfig(projectRoot, options.configFile);
|
|
86
|
+
const nextBuild = await packageDevelopmentHost(
|
|
87
|
+
nextConfig,
|
|
88
|
+
target,
|
|
89
|
+
options.signingIdentity,
|
|
90
|
+
);
|
|
91
|
+
const candidate = await launchApplication(nextBuild, nextConfig, abortController.signal);
|
|
92
|
+
if (stopping) {
|
|
93
|
+
await stopApplication(candidate);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const previous = processes.active;
|
|
97
|
+
processes.activate(candidate);
|
|
98
|
+
config = nextConfig;
|
|
99
|
+
build = nextBuild;
|
|
100
|
+
if (previous) await stopApplication(previous);
|
|
101
|
+
console.log(`[quickgui] Reloaded (pid ${candidate.pid})`);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!stopping) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[quickgui] Reload failed; keeping the previous app running.\n${errorMessage(error)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const queueReload = (path: string | undefined): void => {
|
|
112
|
+
if (stopping) return;
|
|
113
|
+
changedPath = path;
|
|
114
|
+
reloadQueued = true;
|
|
115
|
+
if (reloadPromise) return;
|
|
116
|
+
reloadPromise = (async () => {
|
|
117
|
+
while (reloadQueued && !stopping) {
|
|
118
|
+
reloadQueued = false;
|
|
119
|
+
await reload();
|
|
120
|
+
}
|
|
121
|
+
})().finally(() => {
|
|
122
|
+
reloadPromise = undefined;
|
|
123
|
+
if (reloadQueued && !stopping) queueReload(changedPath);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
let watcher: FSWatcher;
|
|
128
|
+
try {
|
|
129
|
+
watcher = watch(projectRoot, { recursive: true }, (_event, filename) => {
|
|
130
|
+
const path = filename ? resolve(projectRoot, String(filename)) : undefined;
|
|
131
|
+
if (path && shouldIgnoreChange(projectRoot, path, config.outDir)) return;
|
|
132
|
+
if (debounce) clearTimeout(debounce);
|
|
133
|
+
debounce = setTimeout(() => queueReload(path), 80);
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new CliError(`Could not watch ${projectRoot}`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
watcher.on("error", (error) => {
|
|
139
|
+
console.error(`[quickgui] File watcher error: ${errorMessage(error)}`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
try {
|
|
144
|
+
const child = await launchApplication(build, config, abortController.signal);
|
|
145
|
+
processes.activate(child);
|
|
146
|
+
console.log(`[quickgui] App ready (pid ${child.pid}); watching for changes`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error(`[quickgui] App failed to start; watching for changes.\n${errorMessage(error)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await waitForShutdown(abortController);
|
|
152
|
+
} finally {
|
|
153
|
+
stopping = true;
|
|
154
|
+
processes.close();
|
|
155
|
+
abortController.abort();
|
|
156
|
+
if (debounce) clearTimeout(debounce);
|
|
157
|
+
watcher.close();
|
|
158
|
+
if (reloadPromise) await reloadPromise;
|
|
159
|
+
if (processes.active) await stopApplication(processes.active);
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function packageDevelopmentHost(
|
|
165
|
+
config: ResolvedQuickGuiConfig,
|
|
166
|
+
target: QuickGuiTarget,
|
|
167
|
+
signingIdentity?: string,
|
|
168
|
+
): Promise<BuildResult> {
|
|
169
|
+
const started = performance.now();
|
|
170
|
+
const result = await buildProject(config, {
|
|
171
|
+
mode: "development",
|
|
172
|
+
target,
|
|
173
|
+
...(signingIdentity ? { signingIdentity } : {}),
|
|
174
|
+
});
|
|
175
|
+
console.log(`[quickgui] Packaged dev host in ${Math.round(performance.now() - started)} ms`);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function launchApplication(
|
|
180
|
+
build: BuildResult,
|
|
181
|
+
config: ResolvedQuickGuiConfig,
|
|
182
|
+
signal?: AbortSignal,
|
|
183
|
+
): Promise<AppProcess> {
|
|
184
|
+
let ready = false;
|
|
185
|
+
let resolveReady!: () => void;
|
|
186
|
+
let rejectReady!: (error: Error) => void;
|
|
187
|
+
const readyPromise = new Promise<void>((resolvePromise, rejectPromise) => {
|
|
188
|
+
resolveReady = resolvePromise;
|
|
189
|
+
rejectReady = rejectPromise;
|
|
190
|
+
});
|
|
191
|
+
const child = Bun.spawn([build.executablePath], {
|
|
192
|
+
cwd: config.projectRoot,
|
|
193
|
+
env: {
|
|
194
|
+
...process.env,
|
|
195
|
+
NODE_ENV: "development",
|
|
196
|
+
QUICKGUI_DEV: "1",
|
|
197
|
+
},
|
|
198
|
+
stdin: "ignore",
|
|
199
|
+
stdout: "inherit",
|
|
200
|
+
stderr: "inherit",
|
|
201
|
+
ipc(message) {
|
|
202
|
+
if (isReadyMessage(message) && !ready) {
|
|
203
|
+
ready = true;
|
|
204
|
+
resolveReady();
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
void child.exited.then((status) => {
|
|
209
|
+
if (!ready) {
|
|
210
|
+
rejectReady(
|
|
211
|
+
new CliError(`Application exited before its first window was ready (status ${status})`),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const abort = (): void => rejectReady(new CliError("Development launch cancelled"));
|
|
217
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
218
|
+
const timeout = setTimeout(() => {
|
|
219
|
+
rejectReady(new CliError("Application did not report a ready window within 15 seconds"));
|
|
220
|
+
}, 15_000);
|
|
221
|
+
try {
|
|
222
|
+
await readyPromise;
|
|
223
|
+
return child;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await stopApplication(child);
|
|
226
|
+
throw error;
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
signal?.removeEventListener("abort", abort);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function stopApplication(child: AppProcess): Promise<void> {
|
|
234
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
235
|
+
await child.exited;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
child.kill("SIGTERM");
|
|
239
|
+
await Promise.race([child.exited, Bun.sleep(1_500)]);
|
|
240
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
241
|
+
await child.exited;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function shouldIgnoreChange(root: string, path: string, outDir: string): boolean {
|
|
245
|
+
const pathFromRoot = relative(root, path);
|
|
246
|
+
if (pathFromRoot.startsWith("..") || isAbsolute(pathFromRoot)) return true;
|
|
247
|
+
const parts = pathFromRoot.split(sep);
|
|
248
|
+
if (parts.some((part) => [".git", ".quickgui", "node_modules", "target"].includes(part))) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
const outDirFromRoot = relative(root, outDir);
|
|
252
|
+
const outDirIsInsideRoot =
|
|
253
|
+
outDirFromRoot !== "" && !outDirFromRoot.startsWith("..") && !isAbsolute(outDirFromRoot);
|
|
254
|
+
if (!outDirIsInsideRoot) return false;
|
|
255
|
+
const pathFromOutDir = relative(outDir, path);
|
|
256
|
+
return pathFromOutDir === "" ||
|
|
257
|
+
(!pathFromOutDir.startsWith("..") && !isAbsolute(pathFromOutDir));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isReadyMessage(value: unknown): value is { type: "quickgui-ready" } {
|
|
261
|
+
return (
|
|
262
|
+
typeof value === "object" && value !== null && Reflect.get(value, "type") === "quickgui-ready"
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function waitForShutdown(abortController: AbortController): Promise<void> {
|
|
267
|
+
if (abortController.signal.aborted) return;
|
|
268
|
+
await new Promise<void>((resolvePromise) => {
|
|
269
|
+
const requestShutdown = (): void => abortController.abort();
|
|
270
|
+
const finish = (): void => {
|
|
271
|
+
process.off("SIGINT", requestShutdown);
|
|
272
|
+
process.off("SIGTERM", requestShutdown);
|
|
273
|
+
resolvePromise();
|
|
274
|
+
};
|
|
275
|
+
process.once("SIGINT", requestShutdown);
|
|
276
|
+
process.once("SIGTERM", requestShutdown);
|
|
277
|
+
abortController.signal.addEventListener("abort", finish, { once: true });
|
|
278
|
+
});
|
|
279
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
readonly exitCode: number;
|
|
3
|
+
|
|
4
|
+
constructor(message: string, options: ErrorOptions & { exitCode?: number } = {}) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
this.exitCode = options.exitCode ?? 1;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function errorMessage(error: unknown): string {
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
const cause = error.cause instanceof Error ? `\n${errorMessage(error.cause)}` : "";
|
|
14
|
+
return `${error.message}${cause}`;
|
|
15
|
+
}
|
|
16
|
+
return String(error);
|
|
17
|
+
}
|
package/src/index.ts
ADDED