@gjsify/cli 0.40.0 → 0.41.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
@@ -18,7 +18,7 @@
18
18
  import yargs from 'yargs';
19
19
  import { classifyCliFailure } from './cli-fail.js';
20
20
  import { cliVersion } from './utils/publish-headers.js';
21
- import { buildCommand as build, clearCommand as clear, copyCommand as copy, 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, shipCommand as ship, 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, onboardCommand as onboard, 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';
21
+ import { buildCommand as build, clearCommand as clear, copyCommand as copy, 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, shipCommand as ship, 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, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, pruneCommand as prune, 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';
22
22
  import { APP_NAME } from './constants.js';
23
23
  import { isBun, isDeno, isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
24
24
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
@@ -120,6 +120,7 @@ export async function runCli(argv) {
120
120
  .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
121
121
  .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
122
122
  .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
123
+ .command(prune.command, prune.description, prune.builder, prune.handler)
123
124
  .command(upgrade.command, upgrade.description, upgrade.builder, upgrade.handler)
124
125
  .command(format.command, format.description, format.builder, format.handler)
125
126
  .command(lint.command, lint.description, lint.builder, lint.handler)
@@ -1,7 +1,10 @@
1
+ import type { PackageManager } from '@gjsify/create-app';
1
2
  import type { Command } from '../types/index.js';
2
3
  interface CreateOptions {
3
4
  'project-name': string;
4
5
  template?: string;
6
+ runtime?: string;
7
+ 'package-manager'?: PackageManager;
5
8
  force: boolean;
6
9
  install: boolean;
7
10
  }
@@ -1,11 +1,15 @@
1
- import { createProject, discoverTemplates } from '@gjsify/create-app';
2
- import { promptTemplate } from '@gjsify/create-app/prompt';
1
+ import { createProject, discoverTemplates, PACKAGE_MANAGERS, supportedRuntimes } from '@gjsify/create-app';
2
+ import { isCancelled, promptSetup, promptTemplate } from '@gjsify/create-app/prompt';
3
3
  export const createCommand = {
4
4
  command: 'create [project-name]',
5
5
  description: 'Scaffold a new Gjsify project in a new directory.',
6
6
  builder: (yargs) => {
7
7
  const templates = discoverTemplates();
8
8
  const templateChoices = templates.map((t) => t.name);
9
+ // DERIVED from the shipped templates, never a hand-written list:
10
+ // accepting `--runtime deno` is a claim that some template runs there,
11
+ // and only the templates can make it.
12
+ const runtimeChoices = [...new Set(templates.flatMap((t) => supportedRuntimes(t)))];
9
13
  return yargs
10
14
  .positional('project-name', {
11
15
  describe: 'Name of the project directory to create',
@@ -17,6 +21,12 @@ export const createCommand = {
17
21
  describe: 'Template to scaffold from',
18
22
  type: 'string',
19
23
  choices: templateChoices.length > 0 ? templateChoices : undefined,
24
+ })
25
+ .option('runtime', {
26
+ alias: 'r',
27
+ describe: 'Runtime to set the project up for. Decides which package managers are offered and which start script the next steps name.',
28
+ type: 'string',
29
+ choices: runtimeChoices.length > 0 ? runtimeChoices : undefined,
20
30
  })
21
31
  .option('force', {
22
32
  alias: 'f',
@@ -25,30 +35,63 @@ export const createCommand = {
25
35
  default: false,
26
36
  })
27
37
  .option('install', {
28
- describe: 'Run npm install after scaffolding',
38
+ describe: 'Install dependencies after scaffolding',
29
39
  type: 'boolean',
30
40
  default: false,
41
+ })
42
+ .option('package-manager', {
43
+ alias: 'p',
44
+ describe: 'Package manager to install with, and to name in the printed next steps. Must be one the chosen runtime can install for. `gjsify` is the only one that works on a host with no Node.js.',
45
+ type: 'string',
46
+ choices: PACKAGE_MANAGERS,
31
47
  });
32
48
  },
33
49
  handler: async (args) => {
50
+ const interactive = Boolean(process.stdin.isTTY);
51
+ const templates = discoverTemplates();
34
52
  let template = args.template;
35
53
  if (!template) {
36
- const templates = discoverTemplates();
37
- if (!process.stdin.isTTY) {
54
+ if (!interactive) {
38
55
  const list = templates.map((t) => t.name).join(', ');
39
56
  console.error(`Error: --template is required in non-interactive mode. Available templates: ${list || '(none)'}`);
40
57
  // `return` — the deferred GJS exit otherwise fell through into
41
58
  // the interactive template prompt on a non-TTY.
42
59
  return process.exit(1);
43
60
  }
44
- const picked = await promptTemplate(templates);
61
+ // Ctrl+C is an abort, not a crash.
62
+ const picked = await promptTemplate(templates).catch((error) => {
63
+ if (!isCancelled(error))
64
+ throw error;
65
+ console.error('Cancelled.');
66
+ return undefined;
67
+ });
68
+ if (picked === undefined)
69
+ return process.exit(1);
45
70
  template = picked.name;
46
71
  }
72
+ // The SAME decision layer `npm create @gjsify/app` runs, not a second
73
+ // one. Wired late: this command used to pass neither runtime nor package
74
+ // manager, so `createProject` fell back to the host runtime and the first
75
+ // manager for it — meaning `gjsify create --install` on a Node host ran
76
+ // `npm install` without ever saying so, the one default that reaches the
77
+ // user's disk and the one the standalone bin refuses to guess.
78
+ const info = templates.find((t) => t.name === template);
79
+ const setup = await promptSetup({
80
+ offered: info ? supportedRuntimes(info) : [],
81
+ runtime: args.runtime,
82
+ packageManager: args['package-manager'],
83
+ install: args.install,
84
+ interactive,
85
+ });
86
+ if (setup === undefined)
87
+ return process.exit(1);
47
88
  await createProject({
48
89
  projectName: args['project-name'],
49
90
  template,
91
+ runtime: setup.runtime,
50
92
  force: args.force,
51
93
  install: args.install,
94
+ packageManager: setup.packageManager,
52
95
  });
53
96
  },
54
97
  };
@@ -26,6 +26,7 @@ export * from './trust.js';
26
26
  export * from './onboard.js';
27
27
  export * from './self-update.js';
28
28
  export * from './generate-installer.js';
29
+ export * from './prune.js';
29
30
  export * from './uninstall.js';
30
31
  export * from './format.js';
31
32
  export * from './lint.js';
@@ -26,6 +26,7 @@ export * from './trust.js';
26
26
  export * from './onboard.js';
27
27
  export * from './self-update.js';
28
28
  export * from './generate-installer.js';
29
+ export * from './prune.js';
29
30
  export * from './uninstall.js';
30
31
  export * from './format.js';
31
32
  export * from './lint.js';
@@ -7,6 +7,7 @@ interface InstallOptions {
7
7
  'save-peer'?: boolean;
8
8
  'save-optional'?: boolean;
9
9
  immutable?: boolean;
10
+ prune?: boolean;
10
11
  'refresh-lockfile'?: boolean;
11
12
  verbose: boolean;
12
13
  quiet?: boolean;
@@ -32,6 +32,8 @@ import { nodeBinary } from '../utils/run-node.js';
32
32
  import { spawnToCompletion } from '../utils/spawn.js';
33
33
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
34
34
  import { binDirOnPath, BUNDLER_ENGINE_PACKAGE, defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
35
+ import { resolveHostPlatform } from '../utils/platform-check.js';
36
+ import { pruneAfterInstall } from '../utils/prune-prefix.js';
35
37
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
36
38
  /**
37
39
  * Link type for the workspace↔workspace directory links.
@@ -125,6 +127,11 @@ export const installCommand = {
125
127
  description: 'Install a REQUIRED dependency even when its os/cpu/libc excludes the target, instead of failing with EBADPLATFORM (npm config key `force`). Incompatible OPTIONAL dependencies stay skipped — npm ignores --force for those too, and a binary that cannot load is not worth downloading.',
126
128
  type: 'boolean',
127
129
  default: false,
130
+ })
131
+ .option('prune', {
132
+ description: 'After installing, remove packages an earlier install left behind that this host cannot use (foreign os/cpu/libc). Use --no-prune to disable. Skipped automatically under --immutable, and whenever --os/--cpu/--libc is given: an install must never delete against a target the user typed.',
133
+ type: 'boolean',
134
+ default: true,
128
135
  }),
129
136
  handler: async (args) => {
130
137
  // Must run before anything resolves — `--os/--cpu/--libc/--force` are npm
@@ -161,7 +168,7 @@ export const installCommand = {
161
168
  console.warn(`gjsify install --global ignores --${flag}: global installs do not modify a project package.json.`);
162
169
  }
163
170
  }
164
- await installGlobalAndLink(args.packages, { verbose: args.verbose });
171
+ await installGlobalAndLink(args.packages, { verbose: args.verbose, prune: args.prune });
165
172
  return;
166
173
  }
167
174
  // Before the heavy install, so the skew is visible even if a later phase
@@ -170,6 +177,7 @@ export const installCommand = {
170
177
  const backend = args.backend ?? process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
171
178
  if (backend === 'npm') {
172
179
  await projectInstallViaNpm(args);
180
+ pruneProjectPrefix(args);
173
181
  await runPostInstallChecks(args);
174
182
  return;
175
183
  }
@@ -217,6 +225,7 @@ export const installCommand = {
217
225
  hardExitTimerId?.unref?.();
218
226
  try {
219
227
  await projectInstallNative(args, overallController?.signal);
228
+ pruneProjectPrefix(args);
220
229
  await runPostInstallChecks(args);
221
230
  }
222
231
  catch (err) {
@@ -1198,6 +1207,14 @@ async function installGlobalAndLink(specs, opts) {
1198
1207
  const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
1199
1208
  await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
1200
1209
  }
1210
+ // Before the LINK on purpose: `linkGlobalBins`' launchers bake the resolved
1211
+ // prebuild directories into GI_TYPELIB_PATH/LD_LIBRARY_PATH, and must not name
1212
+ // a directory this pass is about to delete.
1213
+ if (opts.prune !== false) {
1214
+ pruneAfterInstall(layout.prefix, resolveHostPlatform({ env: {} }), {
1215
+ hint: 'gjsify prune -g --dry-run',
1216
+ });
1217
+ }
1201
1218
  const created = linkGlobalBins(packageNames, layout);
1202
1219
  reportLinkedBins(created, layout.binDir);
1203
1220
  }
@@ -1205,6 +1222,20 @@ async function installGlobalAndLink(specs, opts) {
1205
1222
  lock.release();
1206
1223
  }
1207
1224
  }
1225
+ /**
1226
+ * The automatic pass for a PROJECT prefix.
1227
+ *
1228
+ * Hooked at the command level rather than inside `installPackages`, so `dlx`'s
1229
+ * throwaway cache prefix — created, used once and dropped — pays nothing for it.
1230
+ */
1231
+ function pruneProjectPrefix(args) {
1232
+ if (args.prune === false)
1233
+ return;
1234
+ pruneAfterInstall(process.cwd(), resolveHostPlatform({ env: {} }), {
1235
+ immutable: args.immutable === true,
1236
+ hint: 'gjsify prune --dry-run',
1237
+ });
1238
+ }
1208
1239
  function reportLinkedBins(created, binDir) {
1209
1240
  if (created.length === 0) {
1210
1241
  console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
@@ -0,0 +1,11 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface PruneOptions {
3
+ global: boolean;
4
+ dryRun: boolean;
5
+ verbose: boolean;
6
+ os?: string;
7
+ cpu?: string;
8
+ libc?: string;
9
+ }
10
+ export declare const pruneCommand: Command<unknown, PruneOptions>;
11
+ export {};
@@ -0,0 +1,84 @@
1
+ // `gjsify prune` — remove installed packages this target cannot use.
2
+ //
3
+ // The repair half of ADR 0025. `install`/`self-update` prune what they would not
4
+ // have placed, but only going forward: a prefix that accreted foreign-platform
5
+ // packages under an older CLI is only reachable by asking. That is this command,
6
+ // and it is why it exists at all rather than the automatic pass alone.
7
+ //
8
+ // `--os/--cpu/--libc` are accepted here and deliberately NOT honoured by the
9
+ // automatic pass: typed on `prune` they are a request ("show me what a darwin-arm64
10
+ // host could not use"), while inherited by an install they would delete the host's
11
+ // own packages. See `automaticPruneRefusal`.
12
+ import { existsSync } from 'node:fs';
13
+ import { acquireInstallLock } from '../utils/install-lock.js';
14
+ import { defaultGlobalLayout } from '../utils/install-global.js';
15
+ import { resolveHostPlatform } from '../utils/platform-check.js';
16
+ import { executePrune, formatPruneReport, planPrune } from '../utils/prune-prefix.js';
17
+ export const pruneCommand = {
18
+ command: 'prune',
19
+ description: 'Remove installed packages this host cannot use (foreign os/cpu/libc). ' +
20
+ 'Reads each package manifest — a package that declares no platform is never touched.',
21
+ builder: (yargs) => yargs
22
+ .option('global', {
23
+ description: 'Prune the user-global XDG prefix (the `install -g` target) instead of the project.',
24
+ type: 'boolean',
25
+ alias: 'g',
26
+ default: false,
27
+ })
28
+ .option('dry-run', {
29
+ description: 'Report what would be removed without touching the filesystem.',
30
+ type: 'boolean',
31
+ default: false,
32
+ })
33
+ .option('verbose', {
34
+ description: 'List every package rather than the first few.',
35
+ type: 'boolean',
36
+ default: false,
37
+ })
38
+ .option('os', {
39
+ description: 'Prune AS IF the host were this OS (linux, darwin, win32). Defaults to this host.',
40
+ type: 'string',
41
+ })
42
+ .option('cpu', {
43
+ description: 'Prune AS IF the host were this architecture (x64, arm64, …). Defaults to this host.',
44
+ type: 'string',
45
+ })
46
+ .option('libc', {
47
+ description: 'Prune AS IF the host used this libc (glibc, musl). Defaults to this host.',
48
+ type: 'string',
49
+ }),
50
+ handler: async (args) => {
51
+ const layout = args.global ? defaultGlobalLayout() : null;
52
+ const prefix = layout ? layout.prefix : process.cwd();
53
+ if (!existsSync(`${prefix}/node_modules`)) {
54
+ console.log(`gjsify prune: nothing installed at ${prefix}`);
55
+ return;
56
+ }
57
+ // The flags are read HERE rather than through the npm config keys, so the
58
+ // automatic pass — which refuses to act on an overridden target — cannot be
59
+ // reached by way of this command's own options.
60
+ const target = resolveHostPlatform({
61
+ env: {
62
+ ...(args.os ? { npm_config_os: args.os } : {}),
63
+ ...(args.cpu ? { npm_config_cpu: args.cpu } : {}),
64
+ ...(args.libc ? { npm_config_libc: args.libc } : {}),
65
+ },
66
+ });
67
+ // A prune racing an install's extract would delete a half-written tree. The
68
+ // lock is the same one every writer of this prefix takes (ADR 0001).
69
+ const lock = await acquireInstallLock(prefix, {});
70
+ let result;
71
+ try {
72
+ result = executePrune(planPrune({ prefix, target }), { dryRun: args.dryRun });
73
+ }
74
+ finally {
75
+ lock.release();
76
+ }
77
+ console.log(formatPruneReport(result, { dryRun: args.dryRun, verbose: args.verbose }));
78
+ // Removing nothing is a SUCCESS — idempotent housekeeping, unlike `uninstall`
79
+ // where naming a package that is not there is a mistake worth reporting. Only
80
+ // a removal the user asked for and did not get is a failure.
81
+ if (result.failed.length > 0)
82
+ return process.exit(1);
83
+ },
84
+ };
@@ -4,6 +4,7 @@ interface SelfUpdateOptions {
4
4
  force?: boolean;
5
5
  tag: string;
6
6
  skipDeps?: boolean;
7
+ prune?: boolean;
7
8
  }
8
9
  export declare const selfUpdateCommand: Command<unknown, SelfUpdateOptions>;
9
10
  /** Verdict of the post-install "did this change what runs?" check. */
@@ -25,7 +25,10 @@ import { dirname, join, posix, resolve, win32 } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { fetchPackument } from '@gjsify/npm-registry';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
+ import { classifyInstall } from '../utils/install-provenance.js';
28
29
  import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
30
+ import { resolveHostPlatform } from '../utils/platform-check.js';
31
+ import { pruneAfterInstall } from '../utils/prune-prefix.js';
29
32
  const PACKAGE_NAME = '@gjsify/cli';
30
33
  export const selfUpdateCommand = {
31
34
  command: 'self-update',
@@ -45,6 +48,11 @@ export const selfUpdateCommand = {
45
48
  description: 'npm dist-tag or pinned version to install (e.g. `latest`, `next`, `0.5.0`).',
46
49
  type: 'string',
47
50
  default: 'latest',
51
+ })
52
+ .option('prune', {
53
+ description: 'After updating, remove packages an earlier install left behind that this host cannot use (foreign os/cpu/libc). Use --no-prune to disable.',
54
+ type: 'boolean',
55
+ default: true,
48
56
  })
49
57
  .option('skip-deps', {
50
58
  description: 'Update only the @gjsify/cli bundle itself; do not pull its runtime dependencies ' +
@@ -60,6 +68,24 @@ export const selfUpdateCommand = {
60
68
  const currentVersion = readCurrentVersion();
61
69
  const installedAtPrefix = existsSync(installedPkgJson);
62
70
  console.log(`Current ${PACKAGE_NAME}: v${currentVersion ?? '(unknown)'}`);
71
+ // REFUSE, do not warn-and-continue, when something else owns this install.
72
+ //
73
+ // Everything below writes the user-global XDG prefix. Under a `.deb`,
74
+ // `.rpm` or Flatpak that produces a SECOND copy which shadows the one the
75
+ // package manager tracks — so the next `apt upgrade` reverts it, or the two
76
+ // diverge, and the user's report is "I updated and nothing changed". A
77
+ // clean refusal naming the right command is strictly more useful than a
78
+ // successful write to the wrong place; #1064 is the same lesson one layer
79
+ // down, where the write succeeded and PATH still resolved the old binary.
80
+ const provenance = classifyInstall({ selfDir: readSelfDir(), xdgPrefix: layout.prefix });
81
+ if (provenance.managedElsewhere) {
82
+ console.error(`\ngjsify self-update: this gjsify is managed by something else — ${provenance.evidence}.\n` +
83
+ `Updating it from here would write ${layout.prefix} and leave you running two installs.\n\n` +
84
+ `Update with: ${provenance.updateWith}\n`);
85
+ // `return process.exit()` — a bare `process.exit()` is DEFERRED under
86
+ // GJS and the handler would run on into the install it just refused.
87
+ return process.exit(1);
88
+ }
63
89
  if (!installedAtPrefix) {
64
90
  console.warn(`\nWarning: no @gjsify/cli install found under ${layout.prefix}.\n` +
65
91
  `self-update only manages installs created by install.mjs or \`gjsify install -g\`.\n` +
@@ -157,6 +183,13 @@ export const selfUpdateCommand = {
157
183
  if (pullDeps) {
158
184
  await installGjsEnginePackages(layout.prefix, target, { verbose: false });
159
185
  }
186
+ // Same ordering reason as `install -g`: before the LINK, whose launchers bake
187
+ // the resolved prebuild directories into their env.
188
+ if (args.prune !== false) {
189
+ pruneAfterInstall(layout.prefix, resolveHostPlatform({ env: {} }), {
190
+ hint: 'gjsify prune -g --dry-run',
191
+ });
192
+ }
160
193
  const linked = linkGlobalBins([PACKAGE_NAME], layout);
161
194
  if (linked.length === 0) {
162
195
  console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
@@ -246,6 +279,28 @@ export function verifyPathResolution(opts) {
246
279
  * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
247
280
  * Node) until it finds a package.json with `name === '@gjsify/cli'`.
248
281
  */
282
+ /**
283
+ * The directory of the running CLI's own package — the input the provenance
284
+ * question is actually about.
285
+ *
286
+ * The same upward walk `readCurrentVersion()` does, and for the same reason: it
287
+ * works from `lib/index.js` under Node and from the published `dist/cli.gjs.mjs`
288
+ * bundle under GJS. Honours the same `GJSIFY_CLI_PACKAGE_JSON` escape hatch, so a
289
+ * test can place a synthetic install anywhere without a real one.
290
+ */
291
+ function readSelfDir() {
292
+ const override = process.env.GJSIFY_CLI_PACKAGE_JSON;
293
+ if (override)
294
+ return dirname(resolve(override));
295
+ const here = fileURLToPath(import.meta.url);
296
+ let dir = dirname(resolve(here));
297
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++) {
298
+ if (existsSync(join(dir, 'package.json')))
299
+ return dir;
300
+ dir = dirname(dir);
301
+ }
302
+ return dirname(resolve(here));
303
+ }
249
304
  function readCurrentVersion() {
250
305
  try {
251
306
  // Escape hatch for tests: point GJSIFY_CLI_PACKAGE_JSON at a synthetic
@@ -188,9 +188,14 @@ function checkGjsVersion() {
188
188
  if (major < 1 || (major === 1 && minor < 86)) {
189
189
  error(`gjs ${major}.${minor} is too old — gjsify requires gjs 1.86 or newer.`);
190
190
  error('Install hints:');
191
- error(' Fedora 43+: sudo dnf install gjs');
192
- error(' Debian 13+: sudo apt install gjs');
193
- error(' Arch: sudo pacman -S gjs');
191
+ error(' Fedora 43+: sudo dnf install gjs');
192
+ error(' Arch: sudo pacman -S gjs');
193
+ // NOT "Debian 13+". Measured 2026-08-16 against tracker.debian.org: trixie
194
+ // (13, stable) ships 1.82.3, forky (testing) 1.88.1, sid 1.89.2 — 1.84 and
195
+ // 1.86 were skipped entirely. Sending a trixie user to `apt install gjs`
196
+ // hands them the very version this branch just rejected, from the branch
197
+ // that rejected it. Same fact, same wording as `utils/ship/depends.ts`.
198
+ error(' Debian forky/sid: sudo apt install gjs (trixie ships 1.82.3 — too old)');
194
199
  exit(1);
195
200
  }
196
201
  }
@@ -126,8 +126,8 @@ export declare const PACKAGE_DEPS: Record<string, string[]>;
126
126
  */
127
127
  export declare function runAllChecks(cwd: string): DepCheck[];
128
128
  /**
129
- * Minimal checks needed to run any GJS example (Node + GJS binaries only).
130
- * Used by `gjsify showcase` for examples that have no native deps.
129
+ * Minimal checks needed to run any GJS example (the GJS binary; Node is reported but
130
+ * not required). Used by `gjsify showcase` for examples that have no native deps.
131
131
  */
132
132
  export declare function runMinimalChecks(): DepCheck[];
133
133
  /**
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
18
  // The zero-dependency subpath, deliberately: importing the plugin root would
19
19
  // pull vite/execa/minify-xml into a system CHECK.
20
20
  import { resolveBlueprintCompiler } from '@gjsify/vite-plugin-blueprint/resolve';
21
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
21
22
  /** Run a binary and return its stdout trimmed, or null if it fails. */
22
23
  function tryExecFile(binary, args) {
23
24
  try {
@@ -378,13 +379,41 @@ export function runAllChecks(cwd) {
378
379
  ];
379
380
  }
380
381
  /**
381
- * Minimal checks needed to run any GJS example (Node + GJS binaries only).
382
- * Used by `gjsify showcase` for examples that have no native deps.
382
+ * Is a Node.js runtime available — asked of the host rather than asserted.
383
+ *
384
+ * This row used to be the literal `{found: true, version: process.version}`, which is
385
+ * a lie on three of the four hosts: `@gjsify/process` stubs `process.version` to
386
+ * `"v20.0.0"` for npm compatibility (Bun and Deno fake `process.versions.node` for the
387
+ * same reason), so `gjsify system-check` under GJS reported `✓ Node.js (v20.0.0)` on a
388
+ * machine with no Node on it — under the NODE-FREE toolchain, whose entire premise is
389
+ * that there is none. `cli-app.ts`'s `runtimeLabel()` already had to know better; one
390
+ * question gets one answer, so this asks the same module.
391
+ *
392
+ * Under Node the running process IS the answer and its version cannot be faked from
393
+ * inside; elsewhere it is a PATH question whose answer may legitimately be "no".
394
+ *
395
+ * `isNode()` rather than `hostRuntime() === 'node'`: the latter RETURNS `'node'` as its
396
+ * fallback for an unrecognised host, which would put an exotic runtime's possibly-stubbed
397
+ * `process.version` on the trusted branch. `isNode()` demands positive evidence.
398
+ *
399
+ * `optional`, never `required`: `gjsify showcase` and `gjsify install` both treat a
400
+ * missing `required` row as fatal, and a GJS showcase needs no Node. The old row could
401
+ * not have failed either gate — it was constant — so nothing that used to pass now
402
+ * fails; what changes is only that the printed line is true.
403
+ */
404
+ function checkNodeRuntime() {
405
+ if (isNode()) {
406
+ return { id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'optional' };
407
+ }
408
+ return checkBinary('nodejs', 'Node.js', 'node', ['--version'], 'optional');
409
+ }
410
+ /**
411
+ * Minimal checks needed to run any GJS example (the GJS binary; Node is reported but
412
+ * not required). Used by `gjsify showcase` for examples that have no native deps.
383
413
  */
384
414
  export function runMinimalChecks() {
385
415
  const results = [];
386
- // Node.js — always present
387
- results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'required' });
416
+ results.push(checkNodeRuntime());
388
417
  // GJS
389
418
  results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], 'required', (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
390
419
  // The one win32 system dependency that is real and was undiagnosed (#997).
@@ -528,6 +557,7 @@ function runOptionalChecks(needed, cwd) {
528
557
  // Entries with multiple space-separated packages are intentional (one dep needs multiple system pkgs).
529
558
  const PM_PACKAGES = {
530
559
  apt: {
560
+ nodejs: 'nodejs',
531
561
  gjs: 'gjs',
532
562
  'blueprint-compiler': 'blueprint-compiler',
533
563
  'pkg-config': 'pkg-config',
@@ -553,6 +583,7 @@ const PM_PACKAGES = {
553
583
  epoxy: 'libepoxy-dev',
554
584
  },
555
585
  dnf: {
586
+ nodejs: 'nodejs',
556
587
  gjs: 'gjs',
557
588
  'blueprint-compiler': 'blueprint-compiler',
558
589
  'pkg-config': 'pkgconf-pkg-config',
@@ -578,6 +609,7 @@ const PM_PACKAGES = {
578
609
  epoxy: 'libepoxy-devel',
579
610
  },
580
611
  pacman: {
612
+ nodejs: 'nodejs',
581
613
  gjs: 'gjs',
582
614
  'blueprint-compiler': 'blueprint-compiler',
583
615
  'pkg-config': 'pkgconf',
@@ -603,6 +635,7 @@ const PM_PACKAGES = {
603
635
  epoxy: 'libepoxy',
604
636
  },
605
637
  zypper: {
638
+ nodejs: 'nodejs',
606
639
  gjs: 'gjs',
607
640
  'blueprint-compiler': 'blueprint-compiler',
608
641
  'pkg-config': 'pkg-config',
@@ -628,6 +661,7 @@ const PM_PACKAGES = {
628
661
  epoxy: 'libepoxy-devel',
629
662
  },
630
663
  apk: {
664
+ nodejs: 'nodejs',
631
665
  gjs: 'gjs',
632
666
  'blueprint-compiler': 'blueprint-compiler',
633
667
  'pkg-config': 'pkgconf',
@@ -675,6 +709,7 @@ const PM_PACKAGES = {
675
709
  // Recorded here so the next reader does not "fix" the gap by guessing a name;
676
710
  // both are genuine platform gaps, not table omissions.
677
711
  brew: {
712
+ nodejs: 'node',
678
713
  gjs: 'gjs',
679
714
  'blueprint-compiler': 'blueprint-compiler',
680
715
  'pkg-config': 'pkgconf',
@@ -750,8 +785,10 @@ export function buildInstallCommand(pm, missing) {
750
785
  'mingw-w64-ucrt-x86_64-libadwaita # inside MSYS2; found automatically afterwards');
751
786
  continue;
752
787
  }
753
- if (dep.id === 'nodejs')
754
- continue; // can't be missing if we're running
788
+ // No `continue` for `nodejs`. It USED to be unmissable — the row was the
789
+ // literal `found: true` — and since it became a real probe it can report
790
+ // absent under GJS, so skipping it here printed "Missing optional: Node.js"
791
+ // above an install command that did not install it.
755
792
  const pkg = pkgMap[dep.id];
756
793
  if (pkg)
757
794
  pkgs.push(pkg);
@@ -0,0 +1,44 @@
1
+ /** Where the running CLI came from. */
2
+ export type InstallProvenanceKind =
3
+ /** `install.mjs` or `gjsify install -g` — the layout `self-update` manages. */
4
+ 'xdg-global'
5
+ /** Inside a Flatpak sandbox. */
6
+ | 'flatpak'
7
+ /** A distro package (`.deb`/`.rpm`) staged under a system prefix. */
8
+ | 'system-package'
9
+ /** `npm install -g` (or yarn/pnpm global) — a `node_modules` tree elsewhere. */
10
+ | 'npm-global'
11
+ /** A checkout, a container image, an unpacked tarball — anything else. */
12
+ | 'unknown';
13
+ export interface InstallProvenance {
14
+ kind: InstallProvenanceKind;
15
+ /** What the decision was made ON, so a wrong verdict is debuggable. */
16
+ evidence: string;
17
+ /** How the user should update, when it is not `self-update`'s job. */
18
+ updateWith?: string;
19
+ /** True when `self-update` must refuse rather than write the XDG prefix. */
20
+ managedElsewhere: boolean;
21
+ }
22
+ export interface ProvenanceInput {
23
+ /** Absolute path of the running CLI's own package directory. */
24
+ selfDir: string;
25
+ /** `defaultGlobalLayout().prefix` — the layout `self-update` owns. */
26
+ xdgPrefix: string;
27
+ env?: Record<string, string | undefined>;
28
+ platform?: NodeJS.Platform;
29
+ }
30
+ /**
31
+ * Classify an installation from its path and environment.
32
+ *
33
+ * ORDER MATTERS and is not alphabetical:
34
+ *
35
+ * 1. **Flatpak first.** Its `/app` prefix would also satisfy no other test, but
36
+ * `FLATPAK_ID` is the unambiguous signal and is present even when the tree is
37
+ * mounted somewhere unusual.
38
+ * 2. **XDG before system.** The XDG prefix is under `$HOME`, so it can never be
39
+ * `/usr` — but a user whose `$HOME` is `/opt/someuser` exists, and asking the
40
+ * narrow question first means that user is not told to run `dnf`.
41
+ * 3. **System before npm.** A `.deb` stages a `node_modules`-shaped tree under
42
+ * `/usr/lib`, so the npm test alone would claim it.
43
+ */
44
+ export declare function classifyInstall(input: ProvenanceInput): InstallProvenance;