@gjsify/cli 0.10.0 → 0.12.0

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/lib/cli-app.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
- import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, } from './commands/index.js';
22
+ import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
24
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
25
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
@@ -114,6 +114,8 @@ export async function runCli(argv) {
114
114
  .command(tsc.command, tsc.description, tsc.builder, tsc.handler)
115
115
  .command(affected.command, affected.description, affected.builder, affected.handler)
116
116
  .command(storybook.command, storybook.description, storybook.builder, storybook.handler)
117
+ .command(debug.command, debug.description, debug.builder, debug.handler)
118
+ .command(browse.command, browse.description, browse.builder, browse.handler)
117
119
  .demandCommand(1)
118
120
  .epilogue(`Running on ${runtimeLabel()}`)
119
121
  .help()
@@ -0,0 +1,16 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface BrowseCliOptions {
3
+ url?: string;
4
+ appId?: string;
5
+ title?: string;
6
+ globals?: string;
7
+ out?: string;
8
+ devtools: boolean;
9
+ inspectorPort?: number;
10
+ screenshot?: string;
11
+ buildOnly: boolean;
12
+ }
13
+ /** Derive a DBus app id from the package name (mirrored by `gjsify debug`). */
14
+ export declare function deriveBrowserAppId(name?: string): string;
15
+ export declare const browseCommand: Command<unknown, BrowseCliOptions>;
16
+ export {};
@@ -0,0 +1,117 @@
1
+ // `gjsify browse [url]` — launch the Adwaita web browser from
2
+ // @gjsify/devtools-browser, optionally at a URL. Generates a tiny entry that
3
+ // calls runBrowserDevtools(), builds it (`--app gjs --globals auto,dom`), and
4
+ // launches it. Dispatch is IN-PROCESS (runCli + runGjsBundle), like
5
+ // `gjsify storybook`. `--devtools` sets GJSIFY_DEVTOOLS=1 so @gjsify/devtools
6
+ // wires the org.gjsify.Devtools control plane (driveable via `gjsify debug`).
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ function readPackage(cwd) {
10
+ try {
11
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ /** Derive a DBus app id from the package name (mirrored by `gjsify debug`). */
18
+ export function deriveBrowserAppId(name) {
19
+ if (!name)
20
+ return 'org.gjsify.Browser';
21
+ const slug = name
22
+ .replace(/^@/, '')
23
+ .replace(/[^a-zA-Z0-9]+/g, '.')
24
+ .replace(/^\.+|\.+$/g, '');
25
+ return `org.gjsify.browser.${slug}`;
26
+ }
27
+ function generateEntry(applicationId, url, title, inspectorPort, screenshot) {
28
+ const titleLine = title ? `\n title: ${JSON.stringify(title)},` : '';
29
+ const urlLine = url ? `\n homeUrl: ${JSON.stringify(url)},` : '';
30
+ const inspectorLine = inspectorPort !== undefined ? `\n inspectorPort: ${inspectorPort},` : '';
31
+ const screenshotLine = screenshot ? `\n screenshot: ${JSON.stringify(screenshot)},` : '';
32
+ return `// Auto-generated by \`gjsify browse\` — do not edit by hand.
33
+ import { runBrowserDevtools } from '@gjsify/devtools-browser';
34
+
35
+ await runBrowserDevtools({
36
+ applicationId: ${JSON.stringify(applicationId)},${titleLine}${urlLine}${inspectorLine}${screenshotLine}
37
+ });
38
+ `;
39
+ }
40
+ export const browseCommand = {
41
+ command: 'browse [url]',
42
+ description: 'Launch the Adwaita web browser, optionally pointed at a URL.',
43
+ builder: (yargs) => yargs
44
+ .positional('url', { description: 'Initial URL to load (default: page:welcome)', type: 'string' })
45
+ .option('app-id', {
46
+ description: 'GApplication id (default: gjsify.browse.applicationId or derived from the package name)',
47
+ type: 'string',
48
+ })
49
+ .option('title', { description: 'Window title', type: 'string' })
50
+ .option('globals', {
51
+ description: 'Value for `gjsify build --globals` (default: auto,dom — WebKit/iframe need DOM globals)',
52
+ type: 'string',
53
+ })
54
+ .option('out', {
55
+ description: 'Output bundle path (default: node_modules/.cache/gjsify-browse)',
56
+ type: 'string',
57
+ })
58
+ .option('devtools', {
59
+ description: 'Enable the MCP devtools control plane (sets GJSIFY_DEVTOOLS=1)',
60
+ type: 'boolean',
61
+ default: false,
62
+ })
63
+ .option('inspector-port', {
64
+ description: "Enable WebKit's remote inspector protocol on this port + the Cdp* methods (implies --devtools)",
65
+ type: 'number',
66
+ })
67
+ .option('screenshot', {
68
+ description: 'One-shot: load the URL, capture a WebKit screenshot to this path, and exit (CI / quick captures).',
69
+ type: 'string',
70
+ })
71
+ .option('build-only', {
72
+ description: 'Build the bundle but do not launch it',
73
+ type: 'boolean',
74
+ default: false,
75
+ }),
76
+ handler: async (args) => {
77
+ const cwd = process.cwd();
78
+ const pkg = readPackage(cwd);
79
+ const config = pkg.gjsify?.browse ?? {};
80
+ const applicationId = args.appId ?? config.applicationId ?? deriveBrowserAppId(pkg.name);
81
+ const title = args.title ?? config.title;
82
+ const globals = args.globals ?? config.globals ?? 'auto,dom';
83
+ const url = args.url;
84
+ const inspectorPort = args.inspectorPort ?? config.inspectorPort;
85
+ // CDP-over-MCP needs the control plane, so --inspector-port implies --devtools.
86
+ const devtools = args.devtools || inspectorPort !== undefined;
87
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-browse');
88
+ mkdirSync(cacheDir, { recursive: true });
89
+ const entryPath = join(cacheDir, 'entry.ts');
90
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'browse.gjs.mjs');
91
+ const screenshotPath = args.screenshot ? resolve(cwd, args.screenshot) : undefined;
92
+ writeFileSync(entryPath, generateEntry(applicationId, url, title, inspectorPort, screenshotPath));
93
+ console.log(`[gjsify browse] app=${applicationId}${url ? ` url=${url}` : ''}${devtools ? ' devtools=1' : ''}${inspectorPort !== undefined ? ` inspector-port=${inspectorPort}` : ''}`);
94
+ const { runCli } = await import('../cli-app.js');
95
+ await runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
96
+ if (args.buildOnly) {
97
+ console.log(`[gjsify browse] built ${outPath}`);
98
+ return;
99
+ }
100
+ // runGjsBundle spreads process.env into the child, so set the devtools
101
+ // gate here (RunGjsBundleOptions has no env field).
102
+ if (devtools)
103
+ process.env.GJSIFY_DEVTOOLS = '1';
104
+ // Set the inspector env in the LAUNCHER so the spawned gjs has it from
105
+ // process start. WebKit reads WEBKIT_INSPECTOR_HTTP_SERVER once, at
106
+ // WebKitInitialize (triggered as the first WebView type initializes —
107
+ // before the app's own constructor runs), so setting it only inside the
108
+ // app is too late to bind the server. This is the authoritative place;
109
+ // the BrowserApplication constructor keeps a best-effort fallback for
110
+ // direct runBrowserDevtools() callers.
111
+ if (inspectorPort !== undefined) {
112
+ process.env.WEBKIT_INSPECTOR_HTTP_SERVER = `127.0.0.1:${inspectorPort}`;
113
+ }
114
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
115
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
116
+ },
117
+ };
@@ -99,7 +99,12 @@ export const buildCommand = {
99
99
  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.",
100
100
  type: 'string',
101
101
  normalize: true,
102
- default: 'auto',
102
+ // No yargs `default: 'auto'` — a yargs default is
103
+ // indistinguishable from a user-set value and would always
104
+ // clobber `package.json#gjsify.globals` / `.gjsifyrc.*` in
105
+ // config.ts (same footgun as `entryPoints`/`bundler.input`).
106
+ // The 'auto' fallback is applied post-merge in `Config.forBuild`
107
+ // so precedence is: CLI flag > config file > 'auto'.
103
108
  })
104
109
  .option('shebang', {
105
110
  description: 'Prepend a target-appropriate shebang to the output and mark it executable (chmod 755): `#!/usr/bin/env -S gjs -m` for --app gjs, `#!/usr/bin/env node` for --app node. Applies to GJS and Node app builds with a single --outfile. Default: false (use --shebang to enable, or set `shebang: true` in `.gjsifyrc.js`).',
@@ -0,0 +1,10 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface DebugCliOptions {
3
+ busName?: string;
4
+ profile?: string;
5
+ globals?: string;
6
+ out?: string;
7
+ buildOnly: boolean;
8
+ }
9
+ export declare const debugCommand: Command<unknown, DebugCliOptions>;
10
+ export {};
@@ -0,0 +1,148 @@
1
+ // `gjsify debug` — launch an MCP↔devtools bridge for a running, devtools-enabled
2
+ // gjsify app (its `org.gjsify.Devtools` DBus control plane). An MCP client (e.g.
3
+ // Claude Code) uses this as its server command; the bridge speaks JSON-RPC on
4
+ // stdio and translates tool calls to the app's DBus interface.
5
+ //
6
+ // Dispatch is IN-PROCESS (runCli + runGjsBundle), like `gjsify storybook`. The
7
+ // generated entry resolves `@gjsify/devtools-mcp` from the CONSUMER's
8
+ // node_modules (so it stays out of the CLI's own deps). The bridge is built
9
+ // `--app gjs --shebang` and launched with stdio inherited, so its JSON-RPC
10
+ // stdout reaches the client untouched.
11
+ //
12
+ // CRITICAL: this command logs ONLY to stderr — stdout is the MCP channel.
13
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ function readPackage(cwd) {
16
+ try {
17
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ /** Mirror of `commands/storybook.ts` deriveAppId — the storybook app's DBus id. */
24
+ function deriveStorybookAppId(name) {
25
+ if (!name)
26
+ return 'org.gjsify.Storybook';
27
+ const slug = name
28
+ .replace(/^@/, '')
29
+ .replace(/[^a-zA-Z0-9]+/g, '.')
30
+ .replace(/^\.+|\.+$/g, '');
31
+ return `org.gjsify.storybook.${slug}`;
32
+ }
33
+ /** Mirror of `commands/browse.ts` deriveBrowserAppId — the browser app's DBus id. */
34
+ function deriveBrowserAppId(name) {
35
+ if (!name)
36
+ return 'org.gjsify.Browser';
37
+ const slug = name
38
+ .replace(/^@/, '')
39
+ .replace(/[^a-zA-Z0-9]+/g, '.')
40
+ .replace(/^\.+|\.+$/g, '');
41
+ return `org.gjsify.browser.${slug}`;
42
+ }
43
+ function hasDep(pkg, name) {
44
+ return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
45
+ }
46
+ function generateEntry(profile, busNameBase, name, version) {
47
+ if (profile === 'storybook') {
48
+ return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
49
+ import { runDevtoolsMcp, storybookProfile } from '@gjsify/devtools-mcp';
50
+
51
+ await runDevtoolsMcp(storybookProfile(${JSON.stringify(busNameBase)}));
52
+ `;
53
+ }
54
+ if (profile === 'browser') {
55
+ return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
56
+ import { runDevtoolsMcp, browserProfile } from '@gjsify/devtools-mcp';
57
+
58
+ await runDevtoolsMcp(browserProfile(${JSON.stringify(busNameBase)}));
59
+ `;
60
+ }
61
+ if (profile === 'cdp') {
62
+ return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
63
+ import { runDevtoolsMcp, cdpProfile } from '@gjsify/devtools-mcp';
64
+
65
+ await runDevtoolsMcp(cdpProfile(${JSON.stringify(busNameBase)}));
66
+ `;
67
+ }
68
+ return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
69
+ import { runDevtoolsMcp } from '@gjsify/devtools-mcp';
70
+
71
+ await runDevtoolsMcp({
72
+ name: ${JSON.stringify(name)},
73
+ version: ${JSON.stringify(version)},
74
+ busNameBase: ${JSON.stringify(busNameBase)},
75
+ });
76
+ `;
77
+ }
78
+ export const debugCommand = {
79
+ command: 'debug',
80
+ description: 'Launch an MCP bridge for a devtools-enabled gjsify app (org.gjsify.Devtools).',
81
+ builder: (yargs) => yargs
82
+ .option('bus-name', {
83
+ description: 'App DBus base name (default: gjsify.devtools.busNameBase, or the storybook app-id)',
84
+ type: 'string',
85
+ })
86
+ .option('profile', {
87
+ description: 'Tool profile: generic | storybook | browser | cdp (default: auto — storybook if @gjsify/storybook is a dep, browser if @gjsify/devtools-browser is, cdp if @gjsify/devtools-cdp is, else generic)',
88
+ type: 'string',
89
+ })
90
+ .option('globals', {
91
+ description: 'Value for `gjsify build --globals` (default: auto)',
92
+ type: 'string',
93
+ })
94
+ .option('out', {
95
+ description: 'Output bundle path (default: node_modules/.cache/gjsify-debug)',
96
+ type: 'string',
97
+ })
98
+ .option('build-only', {
99
+ description: 'Build the bridge bundle but do not launch it (for a .mcp.json command pointing at the bundle)',
100
+ type: 'boolean',
101
+ default: false,
102
+ }),
103
+ handler: async (args) => {
104
+ const cwd = process.cwd();
105
+ const pkg = readPackage(cwd);
106
+ const config = pkg.gjsify?.devtools ?? {};
107
+ const profile = args.profile ??
108
+ config.profile ??
109
+ (hasDep(pkg, '@gjsify/storybook')
110
+ ? 'storybook'
111
+ : hasDep(pkg, '@gjsify/devtools-browser')
112
+ ? 'browser'
113
+ : hasDep(pkg, '@gjsify/devtools-cdp')
114
+ ? 'cdp'
115
+ : 'generic');
116
+ const busNameBase = args.busName ??
117
+ config.busNameBase ??
118
+ (profile === 'storybook'
119
+ ? (pkg.gjsify?.storybook?.applicationId ?? deriveStorybookAppId(pkg.name))
120
+ : profile === 'browser' || profile === 'cdp'
121
+ ? (pkg.gjsify?.browse?.applicationId ?? deriveBrowserAppId(pkg.name))
122
+ : undefined);
123
+ if (!busNameBase) {
124
+ console.error('gjsify debug: no bus name — set package.json#gjsify.devtools.busNameBase or pass --bus-name <org.example.App>');
125
+ process.exit(1);
126
+ }
127
+ const globals = args.globals ?? config.globals ?? 'auto';
128
+ const name = pkg.name ? `${pkg.name}-devtools` : 'gjsify-devtools';
129
+ const version = '0.8.0';
130
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-debug');
131
+ mkdirSync(cacheDir, { recursive: true });
132
+ const entryPath = join(cacheDir, 'entry.ts');
133
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'bridge.gjs.mjs');
134
+ writeFileSync(entryPath, generateEntry(profile, busNameBase, name, version));
135
+ // stderr only — stdout is the MCP JSON-RPC channel.
136
+ console.error(`[gjsify debug] profile=${profile} bus=${busNameBase}`);
137
+ // In-process build (runtime-agnostic), then launch with stdio inherited
138
+ // so the bridge's JSON-RPC stdin/stdout are the client's pipes.
139
+ const { runCli } = await import('../cli-app.js');
140
+ await runCli(['build', entryPath, '--app', 'gjs', '--shebang', '--globals', globals, '--outfile', outPath]);
141
+ if (args.buildOnly) {
142
+ console.error(`[gjsify debug] built ${outPath}`);
143
+ return;
144
+ }
145
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
146
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
147
+ },
148
+ };
@@ -31,3 +31,5 @@ export * from './barrels.js';
31
31
  export * from './tsc.js';
32
32
  export * from './affected.js';
33
33
  export * from './storybook.js';
34
+ export * from './debug.js';
35
+ export * from './browse.js';
@@ -31,3 +31,5 @@ export * from './barrels.js';
31
31
  export * from './tsc.js';
32
32
  export * from './affected.js';
33
33
  export * from './storybook.js';
34
+ export * from './debug.js';
35
+ export * from './browse.js';
@@ -25,7 +25,7 @@ import { discoverWorkspaces } from '@gjsify/workspace';
25
25
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
26
26
  import { detectNativePackages } from '../utils/detect-native-packages.js';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
- import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName } from '../utils/install-global.js';
28
+ import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
29
29
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
30
30
  // Default 30min wall-clock budget for the full install. Big workspaces
31
31
  // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
@@ -920,12 +920,27 @@ async function installGlobalAndLink(specs, opts) {
920
920
  mkdirSync(layout.prefix, { recursive: true });
921
921
  console.log(`gjsify install --global → ${layout.prefix}`);
922
922
  console.log(` bins → ${layout.binDir}`);
923
- await installPackages({
923
+ const result = await installPackages({
924
924
  prefix: layout.prefix,
925
925
  specs,
926
926
  verbose: opts.verbose,
927
927
  });
928
928
  const packageNames = specs.map(specToPackageName);
929
+ // A global GJS install of `@gjsify/cli` must also lay down the GJS-native
930
+ // bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
931
+ // bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
932
+ // `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
933
+ // the native backend doesn't resolve peerDependencies at all — so without
934
+ // this they'd never be installed, and `gjsify build` would hard-fail under
935
+ // GJS ("no usable bundler engine"). Installed best-effort at the cli's
936
+ // resolved version so they move in lockstep with the bundle; a platform
937
+ // with no published prebuild degrades to a warning. Must run BEFORE
938
+ // linkGlobalBins so the launcher's detectNativePackages() bakes the
939
+ // engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
940
+ if (packageNames.includes('@gjsify/cli')) {
941
+ const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
942
+ await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
943
+ }
929
944
  const created = linkGlobalBins(packageNames, layout);
930
945
  if (created.length === 0) {
931
946
  console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
@@ -16,7 +16,7 @@ import { dirname, join, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { fetchPackument } from '@gjsify/npm-registry';
18
18
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
19
- import { defaultGlobalLayout, linkGlobalBins } from '../utils/install-global.js';
19
+ import { defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, } from '../utils/install-global.js';
20
20
  const PACKAGE_NAME = '@gjsify/cli';
21
21
  export const selfUpdateCommand = {
22
22
  command: 'self-update',
@@ -76,12 +76,31 @@ export const selfUpdateCommand = {
76
76
  return;
77
77
  }
78
78
  console.log(`Latest matching --tag ${args.tag}: v${target}`);
79
- if (currentVersion === target && !args.force) {
79
+ // A version match is NOT sufficient to declare "up to date": a global
80
+ // GJS install can be on the right `@gjsify/cli` version yet be MISSING
81
+ // the GJS bundler engine (`@gjsify/rolldown-native`) — it's an optional
82
+ // peer the native backend never resolves, so an install.mjs bootstrap
83
+ // before this fix laid down the cli but not the engine, and `gjsify
84
+ // build` hard-fails ("no usable bundler engine"). When the version
85
+ // matches but the engine is absent, REPAIR it (install the engine +
86
+ // re-link) instead of printing "Already up to date". `--skip-deps`
87
+ // still honours its contract (don't touch on-disk deps) and `--check`
88
+ // only reports, so neither triggers the repair.
89
+ const engineMissing = installedAtPrefix && !hasBundlerEngineInstalled(layout.prefix);
90
+ const needsEngineRepair = engineMissing && !args.skipDeps && !args.check;
91
+ if (currentVersion === target && !args.force && !needsEngineRepair) {
80
92
  console.log(`Already up to date (v${target}).`);
93
+ if (engineMissing && args.skipDeps) {
94
+ console.log(`Note: the GJS bundler engine @gjsify/rolldown-native is not installed — ` +
95
+ `re-run without --skip-deps to repair it (\`gjsify build\` needs it under GJS).`);
96
+ }
81
97
  if (!args.check)
82
98
  console.log(`Run with --force to reinstall anyway.`);
83
99
  return;
84
100
  }
101
+ if (needsEngineRepair && currentVersion === target && !args.force) {
102
+ console.log(`@gjsify/cli is at v${target} but the GJS bundler engine @gjsify/rolldown-native is missing — repairing.`);
103
+ }
85
104
  if (args.check) {
86
105
  console.log(currentVersion ? `Update available: v${currentVersion} → v${target}` : `Install required: → v${target}`);
87
106
  process.exit(1);
@@ -125,6 +144,14 @@ export const selfUpdateCommand = {
125
144
  process.exit(1);
126
145
  return;
127
146
  }
147
+ // Lay down (or repair) the GJS-native engine bridges in lockstep with
148
+ // the bundle — same set + best-effort handling as the global installer.
149
+ // Gated on `pullDeps` so `--skip-deps` keeps its bundle-only contract.
150
+ // Must run BEFORE linkGlobalBins so the launcher's detectNativePackages()
151
+ // bakes the engine's prebuild dirs into the wrapper env preamble.
152
+ if (pullDeps) {
153
+ await installGjsEnginePackages(layout.prefix, target, { verbose: false });
154
+ }
128
155
  const linked = linkGlobalBins([PACKAGE_NAME], layout);
129
156
  if (linked.length === 0) {
130
157
  console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
@@ -129,12 +129,23 @@ export const tscCommand = {
129
129
  // `gjs` is not on PATH (ENOENT), transparently fall back to upstream
130
130
  // `typescript` under Node when available.
131
131
  if (bundlePath) {
132
+ // A failed spawn (notably ENOENT when `gjs` is absent) emits BOTH
133
+ // 'error' AND 'close'. The 'error' handler owns the outcome (Node
134
+ // fallback or a clear exit), so the 'close' handler must NOT also
135
+ // exit — otherwise it races and exits with the spawn errno (e.g.
136
+ // ENOENT → -2), which surfaced as a misleading 254 and pre-empted the
137
+ // fallback (gjsify tsc died instead of degrading to upstream tsc on a
138
+ // gjs-less host such as a CI runner).
139
+ let gjsSpawnFailed = false;
132
140
  const child = spawn('gjs', ['-m', bundlePath, ...tscArgs], { env, stdio: 'inherit' });
133
141
  await new Promise((resolvePromise) => {
134
142
  child.on('close', (code) => {
143
+ if (gjsSpawnFailed)
144
+ return;
135
145
  process.exit(code ?? 1);
136
146
  });
137
147
  child.on('error', (err) => {
148
+ gjsSpawnFailed = true;
138
149
  if (err.code === 'ENOENT' && nodeTscPath) {
139
150
  // No gjs on PATH — run upstream typescript under Node.
140
151
  runNodeTsc();
package/lib/config.js CHANGED
@@ -137,6 +137,12 @@ export class Config {
137
137
  configData.consoleShim = cliArgs.consoleShim;
138
138
  if (cliArgs.globals !== undefined)
139
139
  configData.globals = cliArgs.globals;
140
+ // Fallback applied post-merge (not as a yargs `default:`) so a
141
+ // `globals` declared in package.json#gjsify / `.gjsifyrc.*` survives —
142
+ // a yargs default is indistinguishable from a user value and would
143
+ // overwrite the config one. Precedence: CLI flag > config file > 'auto'
144
+ // (mirrors the `bundler.input` fallback below).
145
+ configData.globals ??= 'auto';
140
146
  if (cliArgs.shebang !== undefined)
141
147
  configData.shebang = cliArgs.shebang;
142
148
  if (cliArgs.excludeGlobals) {
package/lib/index.js CHANGED
@@ -6,4 +6,23 @@
6
6
  // This file is just the executable wrapper around `runCli`.
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { runCli } from './cli-app.js';
9
- await runCli(hideBin(process.argv));
9
+ try {
10
+ await runCli(hideBin(process.argv));
11
+ }
12
+ catch (err) {
13
+ // A command handler (or yargs) rejected. Print the message cleanly and exit
14
+ // 1 on BOTH runtimes. Without this, the rejection escapes the entry's
15
+ // top-level await as an UNHANDLED rejection: Node prints it, but GJS surfaces
16
+ // a scary `Gjs-CRITICAL **: Module … threw an exception` that buries the
17
+ // real, already-actionable message (e.g. "gjsify build: no usable bundler
18
+ // engine under GJS — build the facade first or run under Node"). See #597.
19
+ console.error(err instanceof Error ? err.message : String(err));
20
+ if (process.env.GJSIFY_DEBUG && err instanceof Error && err.stack)
21
+ console.error(err.stack);
22
+ // `process.exitCode` is the gentle Node idiom (streams flush). GJS has no
23
+ // atexit hook and would exit 0 at natural shutdown, so force a non-zero exit
24
+ // via `imports.system.exit` there (no-op on Node, where `imports` is unset).
25
+ process.exitCode = 1;
26
+ const gjs = globalThis.imports;
27
+ gjs?.system?.exit?.(1);
28
+ }
@@ -631,8 +631,14 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
631
631
  const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
632
632
  progress?.beginPhase('download', queue.length);
633
633
  let completed = 0;
634
- const tickProgress = (node) => {
634
+ // Count how many nodes were already correctly materialised on disk and
635
+ // skipped (the npm "unchanged" set). Surfaced in the summary log so a warm
636
+ // install makes it obvious why the phase finished in seconds.
637
+ let skipped = 0;
638
+ const tickProgress = (node, wasSkipped) => {
635
639
  completed++;
640
+ if (wasSkipped)
641
+ skipped++;
636
642
  progress?.update({
637
643
  phase: 'download',
638
644
  current: completed,
@@ -648,30 +654,91 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
648
654
  const splitAt = depth1End < 0 ? queue.length : depth1End;
649
655
  // Serial root pass.
650
656
  while (cursor < splitAt) {
657
+ if (signal?.aborted)
658
+ throw abortError(signal);
651
659
  const node = queue[cursor++];
652
660
  if (!node)
653
661
  break;
654
- await extractOne(node, prefix, npmrc, log, signal);
655
- tickProgress(node);
662
+ const wasSkipped = await extractOne(node, prefix, npmrc, log, signal);
663
+ tickProgress(node, wasSkipped);
656
664
  }
657
665
  // Concurrent nested pass.
658
666
  for (let i = 0; i < concurrency; i++) {
659
667
  workers.push((async () => {
660
668
  while (true) {
669
+ // Honour an aborted overall-install budget inside the pool.
670
+ // Without this, a fired --timeout (or Ctrl-C) only aborts
671
+ // the NETWORK fetches; a tree that is mostly cache-hits /
672
+ // already-extracted keeps churning the extract loop to
673
+ // completion, so the install never actually stops when
674
+ // asked to (a contributor to the observed "it never
675
+ // completed; I killed it" hang).
676
+ if (signal?.aborted)
677
+ throw abortError(signal);
661
678
  const idx = cursor++;
662
679
  if (idx >= queue.length)
663
680
  return;
664
681
  const node = queue[idx];
665
682
  if (!node)
666
683
  return;
667
- await extractOne(node, prefix, npmrc, log, signal);
668
- tickProgress(node);
684
+ const wasSkipped = await extractOne(node, prefix, npmrc, log, signal);
685
+ tickProgress(node, wasSkipped);
669
686
  }
670
687
  })());
671
688
  }
672
689
  await Promise.all(workers);
673
690
  progress?.endPhase('download');
691
+ if (skipped > 0) {
692
+ log('install: %d/%d package(s) already up to date — extracted %d', skipped, queue.length, queue.length - skipped);
693
+ }
674
694
  }
695
+ /**
696
+ * Is `name@version` already correctly materialised at `dest`? Reads
697
+ * `<dest>/package.json` and returns true iff its `name` AND `version` match the
698
+ * resolved node exactly. This is the npm/yarn/pnpm "unchanged node" check — an
699
+ * already-present, correct copy is skipped instead of being `rm`-ed and
700
+ * re-extracted.
701
+ *
702
+ * Conservative by design: a missing / unreadable / unparseable package.json, or
703
+ * ANY name/version mismatch, returns false so the caller falls through to the
704
+ * full rm + extract. The only thing this fast-paths is the exact-match case,
705
+ * which is the overwhelming majority of nodes on a warm re-install (the whole
706
+ * resolved tree minus the genuinely-new subtree). Skipping the re-extract is
707
+ * what turns a warm `gjsify install` on a 2000+-package workspace from a
708
+ * many-minute re-extract of every tarball (each gunzip routed through GJS's
709
+ * Gio.ZlibDecompressor on the single GLib main loop) into a near-instant no-op.
710
+ */
711
+ function isAlreadyExtracted(dest, node) {
712
+ const manifestPath = path.join(dest, 'package.json');
713
+ let raw;
714
+ try {
715
+ raw = fs.readFileSync(manifestPath, 'utf-8');
716
+ }
717
+ catch {
718
+ return false;
719
+ }
720
+ try {
721
+ const parsed = JSON.parse(raw);
722
+ // Match version exactly. Match name too when present — a stale dir from
723
+ // a previous resolve could hold a different package at the same path
724
+ // (e.g. a nested placement that moved), in which case the version alone
725
+ // could coincidentally collide. A missing `name` (rare, malformed) is
726
+ // tolerated as long as the version matches.
727
+ if (parsed.version !== node.version)
728
+ return false;
729
+ if (typeof parsed.name === 'string' && parsed.name !== node.name)
730
+ return false;
731
+ return true;
732
+ }
733
+ catch {
734
+ return false;
735
+ }
736
+ }
737
+ /**
738
+ * Download + extract one node, or skip it when an identical copy is already on
739
+ * disk. Returns `true` when the node was skipped (already up to date), `false`
740
+ * when it was (re-)extracted.
741
+ */
675
742
  async function extractOne(node, prefix, npmrc, log, signal) {
676
743
  const dest = path.join(prefix, node.installPath);
677
744
  // Defense-in-depth against the workspace-source-wipe data-loss bug:
@@ -683,6 +750,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
683
750
  // source dir — the realpath check additionally rejects a `dest` that
684
751
  // resolves THROUGH a symlink into a directory outside node_modules.
685
752
  assertNodeModulesDest(dest, node);
753
+ // Idempotent fast-path: the package is already extracted at the resolved
754
+ // version. Skip the cache read + rm + re-extract entirely — this is the
755
+ // npm/yarn/pnpm default (only added/changed nodes are written), and it is
756
+ // the dominant cost on a warm re-install. Force a full re-extract with
757
+ // GJSIFY_INSTALL_FORCE_EXTRACT=1 (debug / corrupted-tree recovery).
758
+ if (process.env.GJSIFY_INSTALL_FORCE_EXTRACT !== '1' && isAlreadyExtracted(dest, node)) {
759
+ log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
760
+ return true;
761
+ }
686
762
  // Hit the content-addressable cache before touching the network.
687
763
  // Tarballs are immutable per SRI integrity, so a hash hit means the
688
764
  // cached bytes are byte-identical to whatever the registry would
@@ -717,6 +793,21 @@ async function extractOne(node, prefix, npmrc, log, signal) {
717
793
  fs.rmSync(dest, { recursive: true, force: true });
718
794
  fs.mkdirSync(dest, { recursive: true });
719
795
  await extractTarball(bytes, dest);
796
+ return false;
797
+ }
798
+ /**
799
+ * Canonical AbortError carrying the signal's `reason` when it is an Error
800
+ * (e.g. the overall-install timeout sentinel), so `isAbortedFromOverallTimeout`
801
+ * in the install command recognises it and prints the actionable timeout
802
+ * message instead of a raw stack.
803
+ */
804
+ function abortError(signal) {
805
+ const reason = signal && 'reason' in signal ? signal.reason : undefined;
806
+ if (reason instanceof Error)
807
+ return reason;
808
+ const err = new Error('Aborted');
809
+ err.name = 'AbortError';
810
+ return err;
720
811
  }
721
812
  /**
722
813
  * Guard: a tarball may only be extracted into a `node_modules/` directory.