@gjsify/cli 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,20 @@
1
1
  // System dependency checker for gjsify CLI.
2
2
  // Uses execFileSync with explicit argument arrays — no shell injection possible.
3
3
  // All binary names are hardcoded constants, never derived from user input.
4
+ //
5
+ // Severity model:
6
+ // - 'required' → must be present, exit code 1 if missing
7
+ // - 'optional' → nice to have, only warned about, exit code stays 0
8
+ //
9
+ // Conditional checking: when a project's package.json is reachable from cwd,
10
+ // optional system deps are only checked if the corresponding @gjsify/* package
11
+ // is in the project's dependency tree. A user with only @gjsify/fs in their
12
+ // project never sees a warning about libmanette.
4
13
  import { execFileSync } from 'node:child_process';
5
- import { join } from 'node:path';
14
+ import { join, resolve } from 'node:path';
6
15
  import { createRequire } from 'node:module';
7
16
  import { pathToFileURL } from 'node:url';
17
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
18
  /** Run a binary and return its stdout trimmed, or null if it fails. */
9
19
  function tryExecFile(binary, args) {
10
20
  try {
@@ -15,41 +25,41 @@ function tryExecFile(binary, args) {
15
25
  }
16
26
  }
17
27
  /** Check if a binary exists and optionally capture its version output. */
18
- function checkBinary(id, name, binary, versionArgs, parseVersion) {
28
+ function checkBinary(id, name, binary, versionArgs, severity, parseVersion, requiredBy) {
19
29
  const out = tryExecFile(binary, versionArgs);
20
30
  if (out === null)
21
- return { id, name, found: false };
31
+ return { id, name, found: false, severity, requiredBy };
22
32
  const version = parseVersion ? parseVersion(out) : out.split('\n')[0] ?? out;
23
- return { id, name, found: true, version };
33
+ return { id, name, found: true, version, severity, requiredBy };
24
34
  }
25
35
  /** Check a pkg-config library. pkg-config --modversion returns version on stdout. */
26
- function checkPkgConfig(id, name, libName) {
36
+ function checkPkgConfig(id, name, libName, severity, requiredBy) {
27
37
  const version = tryExecFile('pkg-config', ['--modversion', libName]);
28
38
  if (version === null)
29
- return { id, name, found: false };
30
- return { id, name, found: true, version: version.split('\n')[0] };
39
+ return { id, name, found: false, severity, requiredBy };
40
+ return { id, name, found: true, version: version.split('\n')[0], severity, requiredBy };
31
41
  }
32
42
  /**
33
43
  * Check for an npm package. Tries the user's project first (cwd), then falls
34
44
  * back to the CLI's own node_modules. This way a locally installed version
35
45
  * takes precedence, but npx usage still works via the CLI's own dependencies.
36
46
  */
37
- function checkNpmPackage(id, name, packageName, cwd) {
47
+ function checkNpmPackage(id, name, packageName, cwd, severity, requiredBy) {
38
48
  // 1. Try user's project
39
49
  try {
40
50
  const requireFromCwd = createRequire(pathToFileURL(join(cwd, '_check_.js')).href);
41
51
  requireFromCwd.resolve(packageName);
42
- return { id, name, found: true };
52
+ return { id, name, found: true, severity, requiredBy };
43
53
  }
44
54
  catch { /* not in project, try CLI fallback */ }
45
55
  // 2. Fallback: CLI's own node_modules
46
56
  try {
47
57
  const requireFromCli = createRequire(import.meta.url);
48
58
  requireFromCli.resolve(packageName);
49
- return { id, name, found: true };
59
+ return { id, name, found: true, severity, requiredBy };
50
60
  }
51
61
  catch {
52
- return { id, name, found: false };
62
+ return { id, name, found: false, severity, requiredBy };
53
63
  }
54
64
  }
55
65
  export function detectPackageManager() {
@@ -61,56 +71,179 @@ export function detectPackageManager() {
61
71
  }
62
72
  return 'unknown';
63
73
  }
74
+ /** Optional system deps keyed by their DepCheck id. */
75
+ const OPTIONAL_DEPS = {
76
+ manette: { id: 'manette', name: 'libmanette', pkgName: 'manette-0.2' },
77
+ gstreamer: { id: 'gstreamer', name: 'GStreamer', pkgName: 'gstreamer-1.0' },
78
+ 'gst-app': { id: 'gst-app', name: 'GStreamer App', pkgName: 'gstreamer-app-1.0' },
79
+ 'gdk-pixbuf': { id: 'gdk-pixbuf', name: 'GdkPixbuf', pkgName: 'gdk-pixbuf-2.0' },
80
+ pango: { id: 'pango', name: 'Pango', pkgName: 'pango' },
81
+ pangocairo: { id: 'pangocairo', name: 'PangoCairo', pkgName: 'pangocairo' },
82
+ webkitgtk: { id: 'webkitgtk', name: 'WebKitGTK', pkgName: 'webkitgtk-6.0' },
83
+ cairo: { id: 'cairo', name: 'Cairo', pkgName: 'cairo' },
84
+ };
85
+ /**
86
+ * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
87
+ * Generated by walking each package's `gi://` imports (excluding the always-
88
+ * available trio GLib/GObject/Gio).
89
+ *
90
+ * Used to compute the set of optional deps to check for a given project.
91
+ */
92
+ const PACKAGE_DEPS = {
93
+ '@gjsify/gamepad': ['manette'],
94
+ '@gjsify/webaudio': ['gstreamer', 'gst-app'],
95
+ '@gjsify/iframe': ['webkitgtk'],
96
+ '@gjsify/canvas2d': ['gdk-pixbuf', 'pango', 'pangocairo', 'cairo'],
97
+ '@gjsify/canvas2d-core': ['gdk-pixbuf', 'pango', 'pangocairo', 'cairo'],
98
+ '@gjsify/dom-elements': ['gdk-pixbuf'],
99
+ // @gjsify/webgl, @gjsify/event-bridge only need gtk4/gdk which are
100
+ // already in the required set, so they don't need optional entries.
101
+ };
102
+ /** Walk up from cwd looking for the nearest package.json. */
103
+ function findProjectRoot(cwd) {
104
+ let dir = resolve(cwd);
105
+ while (true) {
106
+ if (existsSync(join(dir, 'package.json')))
107
+ return dir;
108
+ const parent = resolve(dir, '..');
109
+ if (parent === dir)
110
+ return null;
111
+ dir = parent;
112
+ }
113
+ }
114
+ /**
115
+ * Discover which @gjsify/* packages a project depends on (transitively),
116
+ * by walking the node_modules tree from the project root. Returns a Set of
117
+ * package names like '@gjsify/webgl', '@gjsify/canvas2d'.
118
+ *
119
+ * If the project root cannot be determined or has no node_modules, returns
120
+ * `null` to signal "check all optional deps" — this matches the historical
121
+ * behaviour where `gjsify check` outside any project shows the full list.
122
+ */
123
+ function discoverGjsifyPackages(cwd) {
124
+ const root = findProjectRoot(cwd);
125
+ if (!root)
126
+ return null;
127
+ const nodeModulesDir = join(root, 'node_modules', '@gjsify');
128
+ if (!existsSync(nodeModulesDir)) {
129
+ // Project exists but no @gjsify/* installed → only need core checks.
130
+ return new Set();
131
+ }
132
+ // Read top-level package.json to determine if @gjsify/cli is the only
133
+ // dep (in which case we still want to warn about everything the CLI
134
+ // transitively brings in). Otherwise scan node_modules for installed packages.
135
+ const pkgJsonPath = join(root, 'package.json');
136
+ let topPkg = {};
137
+ try {
138
+ topPkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
139
+ }
140
+ catch { /* ignore */ }
141
+ const directDeps = {
142
+ ...topPkg.dependencies,
143
+ ...topPkg.devDependencies,
144
+ };
145
+ const found = new Set();
146
+ try {
147
+ for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
148
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
149
+ found.add(`@gjsify/${entry.name}`);
150
+ }
151
+ }
152
+ }
153
+ catch { /* ignore */ }
154
+ // Also include direct deps even if node_modules walk failed
155
+ for (const dep of Object.keys(directDeps)) {
156
+ if (dep.startsWith('@gjsify/'))
157
+ found.add(dep);
158
+ }
159
+ return found;
160
+ }
161
+ /**
162
+ * Compute the set of optional dep ids that should be checked for the
163
+ * current project. Returns null to indicate "check all" (no project context).
164
+ */
165
+ function computeNeededOptionalDeps(cwd) {
166
+ const installedPackages = discoverGjsifyPackages(cwd);
167
+ if (installedPackages === null)
168
+ return null; // no project → check everything
169
+ const needed = new Set();
170
+ for (const pkg of installedPackages) {
171
+ const deps = PACKAGE_DEPS[pkg];
172
+ if (deps)
173
+ for (const id of deps)
174
+ needed.add(id);
175
+ }
176
+ return needed;
177
+ }
64
178
  /**
65
179
  * Run all dependency checks. Used by `gjsify check` to show full system status.
180
+ *
181
+ * Required deps (gjs, gtk4, libsoup3, libadwaita, gobject-introspection,
182
+ * blueprint-compiler, pkg-config, meson) are always checked.
183
+ *
184
+ * Optional deps are checked conditionally based on which @gjsify/* packages
185
+ * the project (resolved from cwd) actually consumes. If no project context
186
+ * is available, all optional deps are checked.
66
187
  */
67
188
  export function runAllChecks(cwd) {
68
- return [...runMinimalChecks(), ...runExtraChecks(cwd)];
189
+ const needed = computeNeededOptionalDeps(cwd);
190
+ return [...runMinimalChecks(), ...runRequiredChecks(cwd), ...runOptionalChecks(needed, cwd)];
69
191
  }
70
192
  /**
71
- * Minimal checks needed to run any GJS example (GJS binary only).
193
+ * Minimal checks needed to run any GJS example (Node + GJS binaries only).
72
194
  * Used by `gjsify showcase` for examples that have no native deps.
73
195
  */
74
196
  export function runMinimalChecks() {
75
197
  const results = [];
76
198
  // Node.js — always present
77
- results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version });
199
+ results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'required' });
78
200
  // GJS
79
- results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
201
+ results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], 'required', (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
80
202
  return results;
81
203
  }
82
- /** Check gwebgl npm package (project first, CLI fallback). */
204
+ /** Check gwebgl npm package (project first, CLI fallback). Optional — only needed by @gjsify/webgl users. */
83
205
  export function checkGwebgl(cwd) {
84
- return checkNpmPackage('gwebgl', 'gwebgl (@gjsify/webgl)', '@gjsify/webgl', cwd);
206
+ return checkNpmPackage('gwebgl', 'gwebgl (@gjsify/webgl)', '@gjsify/webgl', cwd, 'optional', ['@gjsify/webgl']);
85
207
  }
86
208
  /**
87
- * Extra checks for development and full system audit.
209
+ * Required system dependencies always checked, missing → exit 1.
210
+ * Includes the core build toolchain (pkg-config, meson, blueprint-compiler)
211
+ * and the foundational libraries (gtk4, libadwaita, libsoup3,
212
+ * gobject-introspection) that nearly every gjsify app needs.
88
213
  */
89
- function runExtraChecks(cwd) {
214
+ function runRequiredChecks(_cwd) {
90
215
  const results = [];
91
- // Blueprint Compiler
92
- results.push(checkBinary('blueprint-compiler', 'Blueprint Compiler', 'blueprint-compiler', ['--version']));
93
- // pkg-config (needed for library checks)
94
- results.push(checkBinary('pkg-config', 'pkg-config', 'pkg-config', ['--version']));
95
- // Meson (for building native extensions)
96
- results.push(checkBinary('meson', 'Meson', 'meson', ['--version']));
97
- // GTK4
98
- results.push(checkPkgConfig('gtk4', 'GTK4', 'gtk4'));
99
- // libadwaita
100
- results.push(checkPkgConfig('libadwaita', 'libadwaita', 'libadwaita-1'));
101
- // libsoup3
102
- results.push(checkPkgConfig('libsoup3', 'libsoup3', 'libsoup-3.0'));
103
- // WebKitGTKtry 4.1 first, fall back to 4.0
104
- const webkitCheck = checkPkgConfig('webkitgtk', 'WebKitGTK', 'webkit2gtk-4.1');
105
- if (webkitCheck.found) {
106
- results.push(webkitCheck);
107
- }
108
- else {
109
- results.push(checkPkgConfig('webkitgtk', 'WebKitGTK', 'webkitgtk-6.0'));
110
- }
111
- // GObject Introspection
112
- results.push(checkPkgConfig('gobject-introspection', 'GObject Introspection', 'gobject-introspection-1.0'));
113
- // gwebgl — project first, CLI fallback.
216
+ // Build toolchain
217
+ results.push(checkBinary('blueprint-compiler', 'Blueprint Compiler', 'blueprint-compiler', ['--version'], 'required'));
218
+ results.push(checkBinary('pkg-config', 'pkg-config', 'pkg-config', ['--version'], 'required'));
219
+ results.push(checkBinary('meson', 'Meson', 'meson', ['--version'], 'required'));
220
+ // Foundational libraries
221
+ results.push(checkPkgConfig('gtk4', 'GTK4', 'gtk4', 'required'));
222
+ results.push(checkPkgConfig('libadwaita', 'libadwaita', 'libadwaita-1', 'required'));
223
+ results.push(checkPkgConfig('libsoup3', 'libsoup3', 'libsoup-3.0', 'required'));
224
+ results.push(checkPkgConfig('gobject-introspection', 'GObject Introspection', 'gobject-introspection-1.0', 'required'));
225
+ return results;
226
+ }
227
+ /**
228
+ * Optional system dependencies only checked if the corresponding @gjsify/*
229
+ * package is in use. Missing optional deps generate warnings, not errors.
230
+ *
231
+ * @param needed Set of optional dep ids to check, or null to check all.
232
+ * @param cwd Used to resolve the gwebgl npm package check.
233
+ */
234
+ function runOptionalChecks(needed, cwd) {
235
+ const results = [];
236
+ for (const [id, dep] of Object.entries(OPTIONAL_DEPS)) {
237
+ if (needed !== null && !needed.has(id))
238
+ continue;
239
+ const requiredBy = Object.entries(PACKAGE_DEPS)
240
+ .filter(([, ids]) => ids.includes(id))
241
+ .map(([pkg]) => pkg);
242
+ results.push(checkPkgConfig(dep.id, dep.name, dep.pkgName, 'optional', requiredBy));
243
+ }
244
+ // gwebgl npm package — special case (not a pkg-config lib).
245
+ // Always reported (the npm package is bundled with the CLI), but marked
246
+ // optional because only @gjsify/webgl users need it.
114
247
  results.push(checkGwebgl(cwd));
115
248
  return results;
116
249
  }
@@ -125,8 +258,15 @@ const PM_PACKAGES = {
125
258
  gtk4: 'libgtk-4-dev',
126
259
  libadwaita: 'libadwaita-1-dev',
127
260
  libsoup3: 'libsoup-3.0-dev',
128
- webkitgtk: 'libwebkit2gtk-4.1-dev',
261
+ webkitgtk: 'libwebkit2gtk-6.0-dev',
129
262
  'gobject-introspection': 'gobject-introspection libgirepository1.0-dev',
263
+ manette: 'libmanette-0.2-0 gir1.2-manette-0.2',
264
+ gstreamer: 'libgstreamer1.0-dev',
265
+ 'gst-app': 'libgstreamer-plugins-base1.0-dev gir1.2-gst-plugins-base-1.0',
266
+ 'gdk-pixbuf': 'libgdk-pixbuf-2.0-dev',
267
+ pango: 'libpango1.0-dev',
268
+ pangocairo: 'libpango1.0-dev',
269
+ cairo: 'libcairo2-dev',
130
270
  },
131
271
  dnf: {
132
272
  gjs: 'gjs',
@@ -138,6 +278,13 @@ const PM_PACKAGES = {
138
278
  libsoup3: 'libsoup3-devel',
139
279
  webkitgtk: 'webkitgtk6.0-devel',
140
280
  'gobject-introspection': 'gobject-introspection-devel',
281
+ manette: 'libmanette-devel',
282
+ gstreamer: 'gstreamer1-devel',
283
+ 'gst-app': 'gstreamer1-plugins-base-devel',
284
+ 'gdk-pixbuf': 'gdk-pixbuf2-devel',
285
+ pango: 'pango-devel',
286
+ pangocairo: 'pango-devel',
287
+ cairo: 'cairo-devel',
141
288
  },
142
289
  pacman: {
143
290
  gjs: 'gjs',
@@ -147,8 +294,15 @@ const PM_PACKAGES = {
147
294
  gtk4: 'gtk4',
148
295
  libadwaita: 'libadwaita',
149
296
  libsoup3: 'libsoup3',
150
- webkitgtk: 'webkit2gtk-4.1',
297
+ webkitgtk: 'webkitgtk-6.0',
151
298
  'gobject-introspection': 'gobject-introspection',
299
+ manette: 'libmanette',
300
+ gstreamer: 'gstreamer',
301
+ 'gst-app': 'gst-plugins-base',
302
+ 'gdk-pixbuf': 'gdk-pixbuf2',
303
+ pango: 'pango',
304
+ pangocairo: 'pango',
305
+ cairo: 'cairo',
152
306
  },
153
307
  zypper: {
154
308
  gjs: 'gjs',
@@ -158,8 +312,15 @@ const PM_PACKAGES = {
158
312
  gtk4: 'gtk4-devel',
159
313
  libadwaita: 'libadwaita-devel',
160
314
  libsoup3: 'libsoup-3_0-devel',
161
- webkitgtk: 'webkit2gtk3-devel',
315
+ webkitgtk: 'webkitgtk6_0-devel',
162
316
  'gobject-introspection': 'gobject-introspection-devel',
317
+ manette: 'libmanette-0_2-0-devel',
318
+ gstreamer: 'gstreamer-devel',
319
+ 'gst-app': 'gstreamer-plugins-base-devel',
320
+ 'gdk-pixbuf': 'gdk-pixbuf-devel',
321
+ pango: 'pango-devel',
322
+ pangocairo: 'pango-devel',
323
+ cairo: 'cairo-devel',
163
324
  },
164
325
  apk: {
165
326
  gjs: 'gjs',
@@ -169,8 +330,15 @@ const PM_PACKAGES = {
169
330
  gtk4: 'gtk4.0-dev',
170
331
  libadwaita: 'libadwaita-dev',
171
332
  libsoup3: 'libsoup3-dev',
172
- webkitgtk: 'webkit2gtk-4.1-dev',
333
+ webkitgtk: 'webkit2gtk-6.0-dev',
173
334
  'gobject-introspection': 'gobject-introspection-dev',
335
+ manette: 'libmanette-dev',
336
+ gstreamer: 'gstreamer-dev',
337
+ 'gst-app': 'gst-plugins-base-dev',
338
+ 'gdk-pixbuf': 'gdk-pixbuf-dev',
339
+ pango: 'pango-dev',
340
+ pangocairo: 'pango-dev',
341
+ cairo: 'cairo-dev',
174
342
  },
175
343
  unknown: {},
176
344
  };
@@ -17,6 +17,10 @@ export declare function detectNativePackages(startDir: string): NativePackage[];
17
17
  * Reads the nearest package.json to discover dependencies, then checks each
18
18
  * for gjsify native prebuilds metadata.
19
19
  *
20
+ * Also checks the **nearest package.json itself** — a workspace package may
21
+ * have its own prebuilds (e.g. `@gjsify/webgl` running its own test) and
22
+ * never list itself in dependencies.
23
+ *
20
24
  * This complements detectNativePackages() (filesystem walk from CWD) by using
21
25
  * require.resolve() — which handles hoisting, workspaces, and nested node_modules.
22
26
  */
@@ -125,6 +125,10 @@ function findNearestPackageJson(startDir) {
125
125
  * Reads the nearest package.json to discover dependencies, then checks each
126
126
  * for gjsify native prebuilds metadata.
127
127
  *
128
+ * Also checks the **nearest package.json itself** — a workspace package may
129
+ * have its own prebuilds (e.g. `@gjsify/webgl` running its own test) and
130
+ * never list itself in dependencies.
131
+ *
128
132
  * This complements detectNativePackages() (filesystem walk from CWD) by using
129
133
  * require.resolve() — which handles hoisting, workspaces, and nested node_modules.
130
134
  */
@@ -140,6 +144,14 @@ export function resolveNativePackages(fromFilePath) {
140
144
  const pkg = readPackageJson(nearestPkgJson);
141
145
  if (!pkg)
142
146
  return results;
147
+ // Check the nearest package itself (e.g. @gjsify/webgl running its own
148
+ // test bundle — webgl never lists itself in dependencies)
149
+ const ownName = typeof pkg['name'] === 'string' ? pkg['name'] : '';
150
+ if (ownName) {
151
+ const ownNative = checkPackage(dirname(nearestPkgJson), ownName, arch);
152
+ if (ownNative)
153
+ results.push(ownNative);
154
+ }
143
155
  const deps = pkg['dependencies'];
144
156
  if (!deps)
145
157
  return results;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,14 +23,15 @@
23
23
  "cli"
24
24
  ],
25
25
  "dependencies": {
26
- "@gjsify/create-app": "^0.1.8",
27
- "@gjsify/esbuild-plugin-gjsify": "^0.1.8",
28
- "@gjsify/example-dom-canvas2d-fireworks": "^0.1.8",
29
- "@gjsify/example-dom-three-geometry-teapot": "^0.1.8",
30
- "@gjsify/example-dom-three-postprocessing-pixel": "^0.1.8",
31
- "@gjsify/example-node-express-webserver": "^0.1.8",
32
- "@gjsify/node-polyfills": "^0.1.8",
33
- "@gjsify/web-polyfills": "^0.1.8",
26
+ "@gjsify/create-app": "^0.1.9",
27
+ "@gjsify/esbuild-plugin-gjsify": "^0.1.9",
28
+ "@gjsify/example-dom-canvas2d-fireworks": "^0.1.9",
29
+ "@gjsify/example-dom-excalibur-jelly-jumper": "^0.1.9",
30
+ "@gjsify/example-dom-three-geometry-teapot": "^0.1.9",
31
+ "@gjsify/example-dom-three-postprocessing-pixel": "^0.1.9",
32
+ "@gjsify/example-node-express-webserver": "^0.1.9",
33
+ "@gjsify/node-polyfills": "^0.1.9",
34
+ "@gjsify/web-polyfills": "^0.1.9",
34
35
  "cosmiconfig": "^9.0.1",
35
36
  "esbuild": "^0.28.0",
36
37
  "get-tsconfig": "^4.13.7",
@@ -2,7 +2,7 @@ import type { ConfigData } from '../types/index.js';
2
2
  import type { App } from '@gjsify/esbuild-plugin-gjsify';
3
3
  import { build, BuildOptions, BuildResult } from 'esbuild';
4
4
  import { gjsifyPlugin } from '@gjsify/esbuild-plugin-gjsify';
5
- import { resolveGlobalsList, writeRegisterInjectFile } from '@gjsify/esbuild-plugin-gjsify/globals';
5
+ import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/esbuild-plugin-gjsify/globals';
6
6
  import { dirname, extname } from 'path';
7
7
 
8
8
  export class BuildAction {
@@ -75,17 +75,41 @@ export class BuildAction {
75
75
  return results;
76
76
  }
77
77
 
78
+ /**
79
+ * Parse the `--globals` value into { autoMode, extras }.
80
+ * - `auto` → { autoMode: true, extras: '' }
81
+ * - `auto,dom` → { autoMode: true, extras: 'dom' }
82
+ * - `auto,dom,fetch` → { autoMode: true, extras: 'dom,fetch' }
83
+ * - `dom,fetch` → { autoMode: false, extras: 'dom,fetch' }
84
+ * - `none` / `` → { autoMode: false, extras: '' }
85
+ * - `undefined` → { autoMode: true, extras: '' } (default)
86
+ */
87
+ private parseGlobalsValue(value: string | undefined): { autoMode: boolean; extras: string } {
88
+ if (value === undefined) return { autoMode: true, extras: '' };
89
+ if (value === 'none' || value === '') return { autoMode: false, extras: '' };
90
+
91
+ const tokens = value.split(',').map(t => t.trim()).filter(Boolean);
92
+ const hasAuto = tokens.includes('auto');
93
+ const extras = tokens.filter(t => t !== 'auto').join(',');
94
+
95
+ return { autoMode: hasAuto, extras };
96
+ }
97
+
78
98
  /**
79
99
  * Resolve the `--globals` CLI list into a pre-computed inject stub path
80
100
  * that the esbuild plugin will append to its `inject` list. Only runs
81
101
  * for `--app gjs` — Node and browser builds rely on native globals.
102
+ *
103
+ * Used only for the explicit-only path (no `auto` token in the value).
104
+ * The auto path is handled in `buildApp` via the two-pass build.
82
105
  */
83
106
  private async resolveGlobalsInject(
84
107
  app: App,
85
- globals: string | undefined,
108
+ globals: string,
86
109
  verbose: boolean | undefined,
87
110
  ): Promise<string | undefined> {
88
- if (app !== 'gjs' || !globals) return undefined;
111
+ if (app !== 'gjs') return undefined;
112
+ if (!globals) return undefined;
89
113
 
90
114
  const registerPaths = resolveGlobalsList(globals);
91
115
  if (registerPaths.size === 0) return undefined;
@@ -106,13 +130,55 @@ export class BuildAction {
106
130
 
107
131
  const format: 'esm' | 'cjs' = (esbuild?.format as 'esm' | 'cjs') ?? (esbuild?.outfile?.endsWith('.cjs') ? 'cjs' : 'esm');
108
132
 
109
- // Set default outfile if no outdir is set
133
+ // Set default outfile if no outdir is set
110
134
  if(esbuild && !esbuild?.outfile && !esbuild?.outdir && (pgk?.main || pgk?.module)) {
111
135
  esbuild.outfile = esbuild?.format === 'cjs' ? pgk.main || pgk.module : pgk.module || pgk.main;
112
136
  }
113
137
 
114
138
  const { consoleShim, globals } = this.configData;
115
- const autoGlobalsInject = await this.resolveGlobalsInject(app, globals, verbose);
139
+
140
+ const pluginOpts = {
141
+ debug: verbose,
142
+ app,
143
+ format,
144
+ exclude,
145
+ reflection: typescript?.reflection,
146
+ consoleShim,
147
+ };
148
+
149
+ const { autoMode, extras } = this.parseGlobalsValue(globals);
150
+
151
+ // --- Auto mode (with optional extras): iterative multi-pass build ---
152
+ // The extras token is used for cases where the detector cannot
153
+ // statically see a global (e.g. Excalibur indirects globalThis via
154
+ // BrowserComponent.nativeComponent). Common pattern: --globals auto,dom
155
+ if (app === 'gjs' && autoMode) {
156
+ const { injectPath } = await detectAutoGlobals(
157
+ { ...this.getEsBuildDefaults(), ...esbuild, format },
158
+ pluginOpts,
159
+ verbose,
160
+ { extraGlobalsList: extras },
161
+ );
162
+
163
+ const result = await build({
164
+ ...this.getEsBuildDefaults(),
165
+ ...esbuild,
166
+ format,
167
+ plugins: [
168
+ gjsifyPlugin({
169
+ ...pluginOpts,
170
+ autoGlobalsInject: injectPath,
171
+ }),
172
+ ],
173
+ });
174
+
175
+ return [result];
176
+ }
177
+
178
+ // --- Explicit list (no `auto` token) or none mode ---
179
+ const autoGlobalsInject = extras
180
+ ? await this.resolveGlobalsInject(app, extras, verbose)
181
+ : undefined;
116
182
 
117
183
  const result = await build({
118
184
  ...this.getEsBuildDefaults(),
@@ -120,26 +186,12 @@ export class BuildAction {
120
186
  format,
121
187
  plugins: [
122
188
  gjsifyPlugin({
123
- debug: verbose,
124
- app,
125
- format,
126
- exclude,
127
- reflection: typescript?.reflection,
128
- consoleShim,
189
+ ...pluginOpts,
129
190
  autoGlobalsInject,
130
191
  }),
131
192
  ]
132
193
  });
133
194
 
134
- // See https://esbuild.github.io/api/#metafile
135
- // TODO add cli options for this
136
- // if(result.metafile) {
137
- // const outFile = esbuild?.outfile ? esbuild.outfile + '.meta.json' : 'meta.json';
138
- // await writeFile(outFile, JSON.stringify(result.metafile));
139
- // let text = await analyzeMetafile(result.metafile)
140
- // console.log(text)
141
- // }
142
-
143
195
  return [result];
144
196
  }
145
197
 
@@ -89,10 +89,10 @@ export const buildCommand: Command<any, CliBuildOptions> = {
89
89
  default: true
90
90
  })
91
91
  .option('globals', {
92
- description: "Comma-separated list of global identifiers your code needs (e.g. 'fetch,Buffer,process,URL,crypto'). Each identifier is mapped to the corresponding `@gjsify/<pkg>/register` module and injected into the bundle. See the CLI Reference docs for the full list of known identifiers. Only applies to GJS app builds.",
92
+ description: "Comma-separated list of global identifiers, 'auto' (default) to detect automatically from the bundled output, or 'none' to disable. The 'auto' token may be combined with explicit identifiers/groups (e.g. 'auto,dom') for cases where the detector cannot statically see a global because it's accessed via indirection. Each identifier is mapped to the corresponding `@gjsify/<pkg>/register` module and injected into the bundle. See the CLI Reference docs for the full list of known identifiers. Only applies to GJS app builds.",
93
93
  type: 'string',
94
94
  normalize: true,
95
- default: ''
95
+ default: 'auto'
96
96
  })
97
97
  },
98
98
  handler: async (args) => {