@gjsify/cli 0.11.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 +34 -30
- package/lib/commands/browse.d.ts +2 -0
- package/lib/commands/browse.js +29 -5
- package/lib/commands/debug.js +12 -3
- package/lib/commands/install.js +17 -2
- package/lib/commands/self-update.js +29 -2
- package/lib/commands/tsc.js +11 -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/commands/browse.d.ts
CHANGED
package/lib/commands/browse.js
CHANGED
|
@@ -24,14 +24,16 @@ export function deriveBrowserAppId(name) {
|
|
|
24
24
|
.replace(/^\.+|\.+$/g, '');
|
|
25
25
|
return `org.gjsify.browser.${slug}`;
|
|
26
26
|
}
|
|
27
|
-
function generateEntry(applicationId, url, title) {
|
|
27
|
+
function generateEntry(applicationId, url, title, inspectorPort, screenshot) {
|
|
28
28
|
const titleLine = title ? `\n title: ${JSON.stringify(title)},` : '';
|
|
29
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)},` : '';
|
|
30
32
|
return `// Auto-generated by \`gjsify browse\` — do not edit by hand.
|
|
31
33
|
import { runBrowserDevtools } from '@gjsify/devtools-browser';
|
|
32
34
|
|
|
33
35
|
await runBrowserDevtools({
|
|
34
|
-
applicationId: ${JSON.stringify(applicationId)},${titleLine}${urlLine}
|
|
36
|
+
applicationId: ${JSON.stringify(applicationId)},${titleLine}${urlLine}${inspectorLine}${screenshotLine}
|
|
35
37
|
});
|
|
36
38
|
`;
|
|
37
39
|
}
|
|
@@ -57,6 +59,14 @@ export const browseCommand = {
|
|
|
57
59
|
description: 'Enable the MCP devtools control plane (sets GJSIFY_DEVTOOLS=1)',
|
|
58
60
|
type: 'boolean',
|
|
59
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',
|
|
60
70
|
})
|
|
61
71
|
.option('build-only', {
|
|
62
72
|
description: 'Build the bundle but do not launch it',
|
|
@@ -71,12 +81,16 @@ export const browseCommand = {
|
|
|
71
81
|
const title = args.title ?? config.title;
|
|
72
82
|
const globals = args.globals ?? config.globals ?? 'auto,dom';
|
|
73
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;
|
|
74
87
|
const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-browse');
|
|
75
88
|
mkdirSync(cacheDir, { recursive: true });
|
|
76
89
|
const entryPath = join(cacheDir, 'entry.ts');
|
|
77
90
|
const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'browse.gjs.mjs');
|
|
78
|
-
|
|
79
|
-
|
|
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}` : ''}`);
|
|
80
94
|
const { runCli } = await import('../cli-app.js');
|
|
81
95
|
await runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
|
|
82
96
|
if (args.buildOnly) {
|
|
@@ -85,8 +99,18 @@ export const browseCommand = {
|
|
|
85
99
|
}
|
|
86
100
|
// runGjsBundle spreads process.env into the child, so set the devtools
|
|
87
101
|
// gate here (RunGjsBundleOptions has no env field).
|
|
88
|
-
if (
|
|
102
|
+
if (devtools)
|
|
89
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
|
+
}
|
|
90
114
|
const { runGjsBundle } = await import('../utils/run-gjs.js');
|
|
91
115
|
await runGjsBundle(outPath, [], { exitOnSuccess: true });
|
|
92
116
|
},
|
package/lib/commands/debug.js
CHANGED
|
@@ -56,6 +56,13 @@ await runDevtoolsMcp(storybookProfile(${JSON.stringify(busNameBase)}));
|
|
|
56
56
|
import { runDevtoolsMcp, browserProfile } from '@gjsify/devtools-mcp';
|
|
57
57
|
|
|
58
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)}));
|
|
59
66
|
`;
|
|
60
67
|
}
|
|
61
68
|
return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
|
|
@@ -77,7 +84,7 @@ export const debugCommand = {
|
|
|
77
84
|
type: 'string',
|
|
78
85
|
})
|
|
79
86
|
.option('profile', {
|
|
80
|
-
description: 'Tool profile: generic | storybook | browser (default: auto — storybook if @gjsify/storybook is a dep, browser if @gjsify/devtools-browser is, else generic)',
|
|
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)',
|
|
81
88
|
type: 'string',
|
|
82
89
|
})
|
|
83
90
|
.option('globals', {
|
|
@@ -103,12 +110,14 @@ export const debugCommand = {
|
|
|
103
110
|
? 'storybook'
|
|
104
111
|
: hasDep(pkg, '@gjsify/devtools-browser')
|
|
105
112
|
? 'browser'
|
|
106
|
-
: '
|
|
113
|
+
: hasDep(pkg, '@gjsify/devtools-cdp')
|
|
114
|
+
? 'cdp'
|
|
115
|
+
: 'generic');
|
|
107
116
|
const busNameBase = args.busName ??
|
|
108
117
|
config.busNameBase ??
|
|
109
118
|
(profile === 'storybook'
|
|
110
119
|
? (pkg.gjsify?.storybook?.applicationId ?? deriveStorybookAppId(pkg.name))
|
|
111
|
-
: profile === 'browser'
|
|
120
|
+
: profile === 'browser' || profile === 'cdp'
|
|
112
121
|
? (pkg.gjsify?.browse?.applicationId ?? deriveBrowserAppId(pkg.name))
|
|
113
122
|
: undefined);
|
|
114
123
|
if (!busNameBase) {
|
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/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.
|
|
@@ -10,6 +10,47 @@ export interface GlobalLayout {
|
|
|
10
10
|
* `GJSIFY_GLOBAL_PREFIX` and `GJSIFY_GLOBAL_BIN_DIR` escape hatches for tests.
|
|
11
11
|
*/
|
|
12
12
|
export declare function defaultGlobalLayout(): GlobalLayout;
|
|
13
|
+
/**
|
|
14
|
+
* The GJS-native tooling bridges the global CLI needs to run a build under GJS.
|
|
15
|
+
*
|
|
16
|
+
* These are declared OPTIONAL peer dependencies of `@gjsify/cli` (so a plain
|
|
17
|
+
* `npm install @gjsify/cli` on Node — where the npm `rolldown` / `lightningcss`
|
|
18
|
+
* / `oxfmt` crates work — does not force a linux prebuild fetch). But a *global
|
|
19
|
+
* GJS install* exists precisely to run the GJS CLI, where there is NO npm
|
|
20
|
+
* fallback: `@gjsify/rolldown-native` is the only bundler engine (`gjsify build`
|
|
21
|
+
* hard-fails without it), `@gjsify/lightningcss-native` backs CSS lowering, and
|
|
22
|
+
* `@gjsify/oxfmt-native` backs `gjsify format`/`fix`. So the global installer
|
|
23
|
+
* lays them down explicitly, on top of the cli's resolved runtime deps.
|
|
24
|
+
*
|
|
25
|
+
* `@gjsify/rolldown-native` is the load-bearing one (build is dead without it);
|
|
26
|
+
* the other two degrade gracefully at runtime (CSS / format fall back), so all
|
|
27
|
+
* three are installed best-effort: a platform with no published prebuild for
|
|
28
|
+
* the engine just gets a warning + continue, mirroring how the optional-peer
|
|
29
|
+
* story already degrades on Node.
|
|
30
|
+
*/
|
|
31
|
+
export declare const GJS_ENGINE_PACKAGES: readonly ["@gjsify/rolldown-native", "@gjsify/lightningcss-native", "@gjsify/oxfmt-native"];
|
|
32
|
+
/** True when `<prefix>/node_modules/@gjsify/rolldown-native` is laid down. */
|
|
33
|
+
export declare function hasBundlerEngineInstalled(prefix: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort install of the GJS-native engine packages into a global prefix.
|
|
36
|
+
*
|
|
37
|
+
* Each engine is installed in its own `installPackages` call so one engine that
|
|
38
|
+
* has no published prebuild for the current platform (the resolver throws "No
|
|
39
|
+
* version of … satisfies …", or the registry 404s) does NOT abort the others —
|
|
40
|
+
* it warns and continues. The bundler engine (`@gjsify/rolldown-native`) is the
|
|
41
|
+
* one a build truly needs; the failure surfaces a clear, actionable message.
|
|
42
|
+
*
|
|
43
|
+
* `version` is the dist-tag / version the top-level `@gjsify/cli` resolved to,
|
|
44
|
+
* so the engines move in lockstep with the bundle. It may be a concrete version
|
|
45
|
+
* (`0.11.0`) or a dist-tag (`latest`, `next`); both are valid spec suffixes.
|
|
46
|
+
*
|
|
47
|
+
* @param installFn Injected to keep this unit-testable without a real registry —
|
|
48
|
+
* defaults to the production `installPackages` backend.
|
|
49
|
+
*/
|
|
50
|
+
export declare function installGjsEnginePackages(prefix: string, version: string, opts?: {
|
|
51
|
+
verbose?: boolean;
|
|
52
|
+
installFn?: (spec: string) => Promise<void>;
|
|
53
|
+
}): Promise<void>;
|
|
13
54
|
export interface LinkedBin {
|
|
14
55
|
/** The bin-name (key from `bin` / `gjsify.bin`). */
|
|
15
56
|
name: string;
|
|
@@ -39,6 +39,80 @@ export function defaultGlobalLayout() {
|
|
|
39
39
|
binDir: binOverride ?? path.join(home, '.local', 'bin'),
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The GJS-native tooling bridges the global CLI needs to run a build under GJS.
|
|
44
|
+
*
|
|
45
|
+
* These are declared OPTIONAL peer dependencies of `@gjsify/cli` (so a plain
|
|
46
|
+
* `npm install @gjsify/cli` on Node — where the npm `rolldown` / `lightningcss`
|
|
47
|
+
* / `oxfmt` crates work — does not force a linux prebuild fetch). But a *global
|
|
48
|
+
* GJS install* exists precisely to run the GJS CLI, where there is NO npm
|
|
49
|
+
* fallback: `@gjsify/rolldown-native` is the only bundler engine (`gjsify build`
|
|
50
|
+
* hard-fails without it), `@gjsify/lightningcss-native` backs CSS lowering, and
|
|
51
|
+
* `@gjsify/oxfmt-native` backs `gjsify format`/`fix`. So the global installer
|
|
52
|
+
* lays them down explicitly, on top of the cli's resolved runtime deps.
|
|
53
|
+
*
|
|
54
|
+
* `@gjsify/rolldown-native` is the load-bearing one (build is dead without it);
|
|
55
|
+
* the other two degrade gracefully at runtime (CSS / format fall back), so all
|
|
56
|
+
* three are installed best-effort: a platform with no published prebuild for
|
|
57
|
+
* the engine just gets a warning + continue, mirroring how the optional-peer
|
|
58
|
+
* story already degrades on Node.
|
|
59
|
+
*/
|
|
60
|
+
export const GJS_ENGINE_PACKAGES = [
|
|
61
|
+
'@gjsify/rolldown-native',
|
|
62
|
+
'@gjsify/lightningcss-native',
|
|
63
|
+
'@gjsify/oxfmt-native',
|
|
64
|
+
];
|
|
65
|
+
/** True when `<prefix>/node_modules/@gjsify/rolldown-native` is laid down. */
|
|
66
|
+
export function hasBundlerEngineInstalled(prefix) {
|
|
67
|
+
return fs.existsSync(path.join(prefix, 'node_modules', '@gjsify/rolldown-native', 'package.json'));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort install of the GJS-native engine packages into a global prefix.
|
|
71
|
+
*
|
|
72
|
+
* Each engine is installed in its own `installPackages` call so one engine that
|
|
73
|
+
* has no published prebuild for the current platform (the resolver throws "No
|
|
74
|
+
* version of … satisfies …", or the registry 404s) does NOT abort the others —
|
|
75
|
+
* it warns and continues. The bundler engine (`@gjsify/rolldown-native`) is the
|
|
76
|
+
* one a build truly needs; the failure surfaces a clear, actionable message.
|
|
77
|
+
*
|
|
78
|
+
* `version` is the dist-tag / version the top-level `@gjsify/cli` resolved to,
|
|
79
|
+
* so the engines move in lockstep with the bundle. It may be a concrete version
|
|
80
|
+
* (`0.11.0`) or a dist-tag (`latest`, `next`); both are valid spec suffixes.
|
|
81
|
+
*
|
|
82
|
+
* @param installFn Injected to keep this unit-testable without a real registry —
|
|
83
|
+
* defaults to the production `installPackages` backend.
|
|
84
|
+
*/
|
|
85
|
+
export async function installGjsEnginePackages(prefix, version, opts = {}) {
|
|
86
|
+
const verbose = opts.verbose ?? false;
|
|
87
|
+
const installOne = opts.installFn ??
|
|
88
|
+
(async (spec) => {
|
|
89
|
+
// Lazy import to avoid a static cycle (install-backend → … → here)
|
|
90
|
+
// and to keep this module importable from the lighter unit tests.
|
|
91
|
+
const { installPackages } = await import('./install-backend.js');
|
|
92
|
+
await installPackages({ prefix, specs: [spec], verbose });
|
|
93
|
+
});
|
|
94
|
+
for (const name of GJS_ENGINE_PACKAGES) {
|
|
95
|
+
const spec = version ? `${name}@${version}` : name;
|
|
96
|
+
try {
|
|
97
|
+
await installOne(spec);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
+
// `@gjsify/rolldown-native` is the bundler engine — its absence
|
|
102
|
+
// means `gjsify build` (and storybook / anything that builds) will
|
|
103
|
+
// hard-fail under GJS. Make that one loud; the other two degrade
|
|
104
|
+
// gracefully so a plain note suffices.
|
|
105
|
+
if (name === '@gjsify/rolldown-native') {
|
|
106
|
+
console.warn(`\nWarning: could not install the GJS bundler engine ${name}@${version} — ${msg}\n` +
|
|
107
|
+
` \`gjsify build\` (and storybook / flatpak) will fail under GJS until it is present.\n` +
|
|
108
|
+
` This usually means no prebuild is published for this platform (${process.platform}/${process.arch}).`);
|
|
109
|
+
}
|
|
110
|
+
else if (verbose) {
|
|
111
|
+
console.warn(`gjsify install: optional GJS engine ${name}@${version} not installed — ${msg}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
42
116
|
/**
|
|
43
117
|
* Install each top-level package's bin entries into `binDir` as small POSIX
|
|
44
118
|
* `sh` launchers that exec the real bin. Reads each package's installed
|
|
@@ -73,14 +73,38 @@ function makeTtyReporter(stream) {
|
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
-
// ──── Plain reporter —
|
|
76
|
+
// ──── Plain reporter — phase begin/end + periodic heartbeat (non-TTY) ──────
|
|
77
|
+
//
|
|
78
|
+
// On a non-TTY (piped to a log file, CI, or captured output) the bar can't
|
|
79
|
+
// `\r`-overwrite, so per-package lines are suppressed to avoid flooding the
|
|
80
|
+
// log. But emitting NOTHING between `beginPhase` and `endPhase` makes a long
|
|
81
|
+
// download/extract phase look frozen — the exact "no further output for 25+
|
|
82
|
+
// minutes" symptom of a slow install. So we emit a low-frequency heartbeat:
|
|
83
|
+
// at most one line per HEARTBEAT_MS, AND only after at least a few packages of
|
|
84
|
+
// real progress, so a fast warm install (most nodes skipped) stays quiet while
|
|
85
|
+
// a genuinely long phase reports `… N/M (X%)` every few seconds. This mirrors
|
|
86
|
+
// what npm (`--loglevel http`) and pnpm log on non-TTY: progress you can watch
|
|
87
|
+
// without it being one line per file.
|
|
88
|
+
const HEARTBEAT_MS = 5_000;
|
|
77
89
|
function makePlainReporter(stream) {
|
|
90
|
+
let lastHeartbeat = 0;
|
|
91
|
+
let lastReported = 0;
|
|
78
92
|
return {
|
|
79
93
|
beginPhase(phase, total) {
|
|
94
|
+
lastHeartbeat = Date.now();
|
|
95
|
+
lastReported = 0;
|
|
80
96
|
stream.write(`gjsify install: ${PHASE_LABEL[phase]} ${total} package(s)\n`);
|
|
81
97
|
},
|
|
82
|
-
update() {
|
|
83
|
-
|
|
98
|
+
update(ev) {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
if (now - lastHeartbeat < HEARTBEAT_MS)
|
|
101
|
+
return;
|
|
102
|
+
if (ev.current <= lastReported)
|
|
103
|
+
return;
|
|
104
|
+
lastHeartbeat = now;
|
|
105
|
+
lastReported = ev.current;
|
|
106
|
+
const pct = ev.total > 0 ? Math.round((ev.current / ev.total) * 100) : 0;
|
|
107
|
+
stream.write(`gjsify install: ${PHASE_LABEL[ev.phase]} ${ev.current}/${ev.total} (${pct}%)\n`);
|
|
84
108
|
},
|
|
85
109
|
endPhase(phase) {
|
|
86
110
|
stream.write(`gjsify install: ${PHASE_LABEL[phase]} done\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "CLI for Gjsify",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -120,19 +120,19 @@
|
|
|
120
120
|
"cli"
|
|
121
121
|
],
|
|
122
122
|
"dependencies": {
|
|
123
|
-
"@gjsify/buffer": "^0.
|
|
124
|
-
"@gjsify/create-app": "^0.
|
|
125
|
-
"@gjsify/node-globals": "^0.
|
|
126
|
-
"@gjsify/node-polyfills": "^0.
|
|
127
|
-
"@gjsify/npm-registry": "^0.
|
|
128
|
-
"@gjsify/resolve-npm": "^0.
|
|
129
|
-
"@gjsify/rolldown-plugin-gjsify": "^0.
|
|
130
|
-
"@gjsify/rolldown-plugin-pnp": "^0.
|
|
131
|
-
"@gjsify/semver": "^0.
|
|
132
|
-
"@gjsify/tar": "^0.
|
|
133
|
-
"@gjsify/tsc": "^0.
|
|
134
|
-
"@gjsify/web-polyfills": "^0.
|
|
135
|
-
"@gjsify/workspace": "^0.
|
|
123
|
+
"@gjsify/buffer": "^0.12.0",
|
|
124
|
+
"@gjsify/create-app": "^0.12.0",
|
|
125
|
+
"@gjsify/node-globals": "^0.12.0",
|
|
126
|
+
"@gjsify/node-polyfills": "^0.12.0",
|
|
127
|
+
"@gjsify/npm-registry": "^0.12.0",
|
|
128
|
+
"@gjsify/resolve-npm": "^0.12.0",
|
|
129
|
+
"@gjsify/rolldown-plugin-gjsify": "^0.12.0",
|
|
130
|
+
"@gjsify/rolldown-plugin-pnp": "^0.12.0",
|
|
131
|
+
"@gjsify/semver": "^0.12.0",
|
|
132
|
+
"@gjsify/tar": "^0.12.0",
|
|
133
|
+
"@gjsify/tsc": "^0.12.0",
|
|
134
|
+
"@gjsify/web-polyfills": "^0.12.0",
|
|
135
|
+
"@gjsify/workspace": "^0.12.0",
|
|
136
136
|
"cosmiconfig": "^9.0.2",
|
|
137
137
|
"get-tsconfig": "^4.14.0",
|
|
138
138
|
"pkg-types": "^2.3.1",
|
|
@@ -140,13 +140,13 @@
|
|
|
140
140
|
"yargs": "^18.0.0"
|
|
141
141
|
},
|
|
142
142
|
"devDependencies": {
|
|
143
|
-
"@gjsify/unit": "^0.
|
|
143
|
+
"@gjsify/unit": "^0.12.0",
|
|
144
144
|
"@types/yargs": "^17.0.35",
|
|
145
145
|
"typescript": "^6.0.3"
|
|
146
146
|
},
|
|
147
147
|
"peerDependencies": {
|
|
148
|
-
"@gjsify/oxfmt-native": "^0.
|
|
149
|
-
"@gjsify/rolldown-native": "^0.
|
|
148
|
+
"@gjsify/oxfmt-native": "^0.12.0",
|
|
149
|
+
"@gjsify/rolldown-native": "^0.12.0"
|
|
150
150
|
},
|
|
151
151
|
"peerDependenciesMeta": {
|
|
152
152
|
"@gjsify/oxfmt-native": {
|