@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/dist/cli.gjs.mjs +95 -69
- package/lib/cli-app.js +3 -1
- package/lib/commands/browse.d.ts +16 -0
- package/lib/commands/browse.js +117 -0
- package/lib/commands/build.js +6 -1
- package/lib/commands/debug.d.ts +10 -0
- package/lib/commands/debug.js +148 -0
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.js +17 -2
- package/lib/commands/self-update.js +29 -2
- package/lib/commands/tsc.js +11 -0
- package/lib/config.js +6 -0
- package/lib/index.js +20 -1
- package/lib/utils/install-backend-native.js +96 -5
- package/lib/utils/install-global.d.ts +41 -0
- package/lib/utils/install-global.js +74 -0
- package/lib/utils/install-progress.js +27 -3
- package/package.json +17 -17
- package/showcases.json +7 -0
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
|
+
};
|
package/lib/commands/build.js
CHANGED
|
@@ -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
|
+
};
|
package/lib/commands/index.d.ts
CHANGED
package/lib/commands/index.js
CHANGED
package/lib/commands/install.js
CHANGED
|
@@ -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
|
-
|
|
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.');
|
package/lib/commands/tsc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|