@gjsify/cli 0.40.0 → 0.42.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.
Files changed (72) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1043 -404
  3. package/lib/actions/build.d.ts +14 -0
  4. package/lib/actions/build.js +54 -7
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +3 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/create.d.ts +3 -0
  9. package/lib/commands/create.js +49 -6
  10. package/lib/commands/debug.js +1 -1
  11. package/lib/commands/dev.d.ts +13 -0
  12. package/lib/commands/dev.js +132 -0
  13. package/lib/commands/dlx.js +1 -1
  14. package/lib/commands/index.d.ts +2 -0
  15. package/lib/commands/index.js +2 -0
  16. package/lib/commands/install.d.ts +1 -0
  17. package/lib/commands/install.js +32 -1
  18. package/lib/commands/prune.d.ts +11 -0
  19. package/lib/commands/prune.js +84 -0
  20. package/lib/commands/run.js +38 -85
  21. package/lib/commands/self-update.d.ts +1 -0
  22. package/lib/commands/self-update.js +55 -0
  23. package/lib/commands/ship.js +6 -1
  24. package/lib/commands/showcase.js +1 -1
  25. package/lib/commands/storybook.js +17 -74
  26. package/lib/commands/test.js +12 -16
  27. package/lib/commands/tsc.js +96 -45
  28. package/lib/config.js +4 -0
  29. package/lib/templates/install.mjs.tmpl +8 -3
  30. package/lib/types/config-data.d.ts +27 -0
  31. package/lib/utils/app-metadata.js +27 -6
  32. package/lib/utils/check-system-deps.d.ts +2 -2
  33. package/lib/utils/check-system-deps.js +92 -13
  34. package/lib/utils/daemon-command.d.ts +7 -0
  35. package/lib/utils/daemon-command.js +30 -0
  36. package/lib/utils/dev-plan.d.ts +52 -0
  37. package/lib/utils/dev-plan.js +117 -0
  38. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  39. package/lib/utils/gi-runtime-paths.js +84 -0
  40. package/lib/utils/gi-typelib.d.ts +49 -0
  41. package/lib/utils/gi-typelib.js +160 -0
  42. package/lib/utils/install-provenance.d.ts +44 -0
  43. package/lib/utils/install-provenance.js +106 -0
  44. package/lib/utils/node-script.d.ts +4 -1
  45. package/lib/utils/node-script.js +27 -3
  46. package/lib/utils/npm-oidc.js +8 -5
  47. package/lib/utils/prune-prefix.d.ts +83 -0
  48. package/lib/utils/prune-prefix.js +333 -0
  49. package/lib/utils/run-gjs.d.ts +12 -1
  50. package/lib/utils/run-gjs.js +24 -31
  51. package/lib/utils/run-node.d.ts +9 -4
  52. package/lib/utils/run-node.js +34 -41
  53. package/lib/utils/ship/depends.d.ts +5 -0
  54. package/lib/utils/ship/depends.js +38 -0
  55. package/lib/utils/ship/discover.js +106 -1
  56. package/lib/utils/ship/launcher.js +27 -4
  57. package/lib/utils/ship/payload.d.ts +35 -0
  58. package/lib/utils/ship/payload.js +84 -0
  59. package/lib/utils/ship/plan.js +20 -0
  60. package/lib/utils/ship/settings.d.ts +8 -1
  61. package/lib/utils/ship/settings.js +13 -1
  62. package/lib/utils/ship/types.d.ts +19 -0
  63. package/lib/utils/simple-command.d.ts +16 -0
  64. package/lib/utils/simple-command.js +77 -0
  65. package/lib/utils/spawn.d.ts +7 -1
  66. package/lib/utils/spawn.js +34 -7
  67. package/lib/utils/system-gi.d.ts +24 -0
  68. package/lib/utils/system-gi.js +14 -3
  69. package/lib/utils/watch-loop.d.ts +91 -0
  70. package/lib/utils/watch-loop.js +207 -0
  71. package/package.json +27 -18
  72. package/lib/templates/app/desktop.tmpl +0 -10
@@ -166,11 +166,25 @@ export declare class BuildAction {
166
166
  */
167
167
  globals?: string;
168
168
  excludeGlobals?: string[];
169
+ /**
170
+ * Allow a `@gjsify/*` this project cannot resolve to come from the copy
171
+ * installed beside the RUNNING CLI.
172
+ *
173
+ * OPT-IN PER CALLER, never inferred here, because this helper does NOT
174
+ * only bundle toolchain — the `node` shim `gjsify run` puts on the PATH
175
+ * routes any project's `node <file>` through `utils/node-script.ts` and
176
+ * into this same function, a consumer's `"start": "node ./src/main.mjs"`
177
+ * included. Each caller states what it is bundling and why that earns the
178
+ * fallback; `gjsify build` sets nothing, so a user's missing dependency
179
+ * stays fatal there.
180
+ */
181
+ resolveFromToolchain?: boolean;
169
182
  }): Promise<string>;
170
183
  /** Application mode */
171
184
  buildApp(app?: App, opts?: {
172
185
  watch?: boolean;
173
186
  preserveDefaultExport?: boolean;
187
+ toolchainAnchor?: string;
174
188
  }): Promise<RolldownOutput[]>;
175
189
  /**
176
190
  * Drive `rolldown.watch(...)`: rebuild on source change, apply the
@@ -5,12 +5,13 @@ import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
7
  import { basename, dirname, extname, join, resolve } from 'node:path';
8
- import { pathToFileURL } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
12
  import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
13
13
  import { assertGjsBundleLoadable } from '../utils/gjs-bundle-guard.js';
14
+ import { giSystemProbes } from '../utils/gi-runtime-paths.js';
14
15
  import { escapeRawNulForGjs } from '../utils/gjs-source-escape.js';
15
16
  import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
16
17
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
@@ -78,17 +79,41 @@ async function newestLockfileMtime(cwd) {
78
79
  }
79
80
  return newest;
80
81
  }
82
+ /**
83
+ * Absolute path of the CLI module that is running, or `null` when it did not come
84
+ * from a `file:` URL.
85
+ *
86
+ * The conversion happens ONCE, here, inside a catch. A CLI loaded from a non-`file:`
87
+ * origin — the bundled/Flatpak shape `commands/tsc.ts` names as the reason its second
88
+ * anchor exists — would otherwise make `fileURLToPath` throw on the path that loads
89
+ * every config file and every plugin, not merely lose the fallback.
90
+ */
91
+ function runningCliFile() {
92
+ try {
93
+ return fileURLToPath(import.meta.url);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
81
99
  /**
82
100
  * Fresh when the cached GJS bundle is newer than BOTH the source file AND the
83
101
  * project's lockfile (the dep-change signal). Missing bundle/source → not fresh
84
102
  * (rebuild). Shared by the plugin + config GJS-bundle caches.
103
+ *
104
+ * `anchorFile` is the running CLI, and only set when this bundle was allowed the
105
+ * toolchain fallback. Such a bundle can carry code from the CLI's OWN install,
106
+ * which neither the entry mtime nor the project lockfile tracks — so after a
107
+ * `gjsify self-update` a cached config/plugin bundle holding the previous CLI's
108
+ * `@gjsify/*` would otherwise stay "fresh" forever.
85
109
  */
86
- async function isBundleFresh(outfile, sourcePath, cwd) {
110
+ async function isBundleFresh(outfile, sourcePath, cwd, anchorFile) {
87
111
  try {
88
112
  const outStat = await stat(outfile);
89
113
  const srcStat = await stat(sourcePath);
90
114
  const depMtime = await newestLockfileMtime(cwd);
91
- return outStat.mtimeMs >= Math.max(srcStat.mtimeMs, depMtime);
115
+ const anchorMtime = anchorFile === undefined ? 0 : (await stat(anchorFile)).mtimeMs;
116
+ return outStat.mtimeMs >= Math.max(srcStat.mtimeMs, depMtime, anchorMtime);
92
117
  }
93
118
  catch {
94
119
  return false;
@@ -433,6 +458,9 @@ export class BuildAction {
433
458
  label: pluginName,
434
459
  verbose,
435
460
  noCacheEnv: 'GJSIFY_NO_PLUGIN_CACHE',
461
+ // A bundler plugin runs inside the CLI process — toolchain by
462
+ // construction, not by where its file happens to sit.
463
+ resolveFromToolchain: true,
436
464
  });
437
465
  }
438
466
  catch (err) {
@@ -471,9 +499,13 @@ export class BuildAction {
471
499
  // policies is two artifacts, and the freshness check below (entry mtime + lockfile)
472
500
  // cannot see the `package.json` edit that changed one. Callers without a policy keep
473
501
  // their existing cache filenames.
474
- const cacheKey = opts.globals === undefined && opts.excludeGlobals === undefined
502
+ // The anchor belongs in the key for the same reason: with it the artifact may
503
+ // contain code from a DIFFERENT install than without it, and two CLIs on one
504
+ // machine resolve to two different paths.
505
+ const anchorFile = opts.resolveFromToolchain === true ? runningCliFile() : null;
506
+ const cacheKey = opts.globals === undefined && opts.excludeGlobals === undefined && anchorFile === null
475
507
  ? inputPath
476
- : `${inputPath} ${JSON.stringify([opts.globals ?? null, opts.excludeGlobals ?? null])}`;
508
+ : `${inputPath} ${JSON.stringify([opts.globals ?? null, opts.excludeGlobals ?? null, anchorFile])}`;
477
509
  // THE HASH GOES IN THE DIRECTORY, THE FILE KEEPS THE SOURCE'S NAME. As
478
510
  // `<name>-<hash>.mjs` it broke every script guarding its body with the standard
479
511
  // is-entry test: `audit-runtimes.mjs` asks whether `process.argv[1]` ENDS WITH its
@@ -482,7 +514,7 @@ export class BuildAction {
482
514
  // does — and a name is cheaper to keep right than an argv to rewrite.
483
515
  const outfile = join(cacheDir, `${safeName}-${shortHash(cacheKey)}`, `${basename(inputPath).replace(/\.[cm]?[jt]sx?$/i, '')}.mjs`);
484
516
  const cacheDisabled = opts.cache === false || (opts.noCacheEnv ? isTruthyEnv(process.env[opts.noCacheEnv]) : false);
485
- if (!cacheDisabled && (await isBundleFresh(outfile, inputPath, cwd))) {
517
+ if (!cacheDisabled && (await isBundleFresh(outfile, inputPath, cwd, anchorFile ?? undefined))) {
486
518
  if (opts.verbose)
487
519
  console.debug(`[gjsify] reusing cached GJS bundle ${outfile}`);
488
520
  return outfile;
@@ -499,7 +531,13 @@ export class BuildAction {
499
531
  output: { file: outfile },
500
532
  ...(opts.define ? { transform: { define: opts.define } } : {}),
501
533
  },
502
- }).buildApp('gjs', { preserveDefaultExport: opts.preserveDefaultExport ?? true });
534
+ }).buildApp('gjs', {
535
+ preserveDefaultExport: opts.preserveDefaultExport ?? true,
536
+ // See `opts.resolveFromToolchain`: the CALLER decides whether this input is
537
+ // toolchain, because this helper alone cannot tell a config file from a
538
+ // consumer's `node ./src/main.mjs`.
539
+ ...(anchorFile === null ? {} : { toolchainAnchor: anchorFile }),
540
+ });
503
541
  return outfile;
504
542
  }
505
543
  /** Application mode */
@@ -558,6 +596,7 @@ export class BuildAction {
558
596
  consoleShim,
559
597
  ...(aliases ? { aliases } : {}),
560
598
  ...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
599
+ ...(opts.toolchainAnchor !== undefined ? { toolchainAnchor: opts.toolchainAnchor } : {}),
561
600
  };
562
601
  const { autoMode, extras } = this.parseGlobalsValue(globals);
563
602
  const pnp = await buildPnpPlugin();
@@ -682,6 +721,13 @@ export class BuildAction {
682
721
  else if (extras) {
683
722
  pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
684
723
  }
724
+ // Fills the byte-1 GI prologue — built across #1152/#1160/#1026 and called by
725
+ // nothing, so every `--app gjs` bundle ever built shipped the empty string
726
+ // `giRuntimePathsStub` returns for an empty input. Only the FINAL build: the
727
+ // two analysis passes above bundle in memory to be PARSED for free globals,
728
+ // never written and never run, so a prologue there is work whose result is
729
+ // discarded.
730
+ const giProbes = app === 'gjs' ? giSystemProbes() : undefined;
685
731
  // Final build: orchestrator → rolldown → write
686
732
  const cfg = await gjsifyPlugin({
687
733
  input: userBundler.input,
@@ -690,6 +736,7 @@ export class BuildAction {
690
736
  userBanner,
691
737
  userAliases: aliases,
692
738
  shebang: this.configData.shebang,
739
+ giSystemProbes: giProbes,
693
740
  }, pluginOpts);
694
741
  const merged = mergeBundlerOptions(cfg.options, userBundler);
695
742
  const finalOpts = {
@@ -2,8 +2,16 @@
2
2
  // npm `rolldown(opts).write(opts.output)`; GJS gets `@gjsify/rolldown-native` (the
3
3
  // Vala/Rust prebuild) via `bundleWithPlugins()`, with `runNativeBundle` replicating
4
4
  // npm rolldown's `.write()` including nested chunk/asset subdirectories. The native
5
- // engine is the GJS DEFAULT with no env var, because npm `rolldown` is a Rust N-API
6
- // addon that cannot load under GJS at all.
5
+ // engine is the GJS DEFAULT with no env var, because npm `rolldown` does not run
6
+ // under GJS. Where it fails matters: NOT in the N-API addon, but in the JS entry
7
+ // above it. `rolldown/dist/shared/binding-*.mjs` evaluates
8
+ // `createRequire(import.meta.url)` at module scope and its platform detection then
9
+ // calls `__require('node:fs')` / `__require('node:child_process')`; GJS refuses a
10
+ // synchronous require of a builtin, so the load dies before any `.node` is opened.
11
+ // The distinction is not pedantry — it says an N-API host alone would not be
12
+ // enough; the wrapper has to be bypassed too, which is exactly what
13
+ // `napiNodeAddonPlugin` does for napi-rs packages. See `status/open-todos.md`
14
+ // § "Can `@gjsify/napi` retire the hand-written `-native` bridges?".
7
15
  //
8
16
  // `GJSIFY_BUNDLER=native|npm` overrides that: `native` throws when the prebuild is
9
17
  // not loadable for the running architecture instead of silently switching engines;
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, devCommand as dev, 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)
@@ -128,6 +129,7 @@ export async function runCli(argv) {
128
129
  .command(tsc.command, tsc.description, tsc.builder, tsc.handler)
129
130
  .command(affected.command, affected.description, affected.builder, affected.handler)
130
131
  .command(storybook.command, storybook.description, storybook.builder, storybook.handler)
132
+ .command(dev.command, dev.description, dev.builder, dev.handler)
131
133
  .command(debug.command, debug.description, debug.builder, debug.handler)
132
134
  .command(browse.command, browse.description, browse.builder, browse.handler)
133
135
  .demandCommand(1)
@@ -112,6 +112,6 @@ export const browseCommand = {
112
112
  process.env.WEBKIT_INSPECTOR_HTTP_SERVER = `127.0.0.1:${inspectorPort}`;
113
113
  }
114
114
  const { runGjsBundle } = await import('../utils/run-gjs.js');
115
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
115
+ await runGjsBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
116
116
  },
117
117
  };
@@ -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
  };
@@ -158,6 +158,6 @@ export const debugCommand = {
158
158
  return;
159
159
  }
160
160
  const { runGjsBundle } = await import('../utils/run-gjs.js');
161
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
161
+ await runGjsBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
162
162
  },
163
163
  };
@@ -0,0 +1,13 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface DevCliOptions {
3
+ entry?: string;
4
+ runtime?: string;
5
+ script?: string;
6
+ globals?: string;
7
+ outfile?: string;
8
+ watchDir?: string;
9
+ debounce: number;
10
+ buildOnly: boolean;
11
+ }
12
+ export declare const devCommand: Command<unknown, DevCliOptions>;
13
+ export {};
@@ -0,0 +1,132 @@
1
+ // `gjsify dev` — the inner development loop: watch, rebuild, relaunch.
2
+ //
3
+ // It exists because `gjsify build --watch` cannot serve it on the host that
4
+ // needs it most. That flag drives rolldown's watcher API, which only the npm
5
+ // engine exposes (`bundler-pick.ts` refuses under `@gjsify/rolldown-native`), so
6
+ // on a Node-free GJS host the loop was manual: edit, Ctrl-C, rebuild, relaunch.
7
+ // `gjsify storybook --watch` had already shown the way around it — watch with
8
+ // `fs.watch` and rebuild by re-entering the ordinary build command, which needs
9
+ // no watcher API and is runtime-agnostic. That loop is now shared
10
+ // (`utils/watch-loop.ts`) and this command is its second caller.
11
+ //
12
+ // WHAT to build is not re-declared here: it is read out of the project's own
13
+ // `build:gjs` / `build:node` script (`utils/dev-plan.ts`), so the dev loop and
14
+ // `gjsify run build` cannot drift into building different bundles.
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join, relative, resolve } from 'node:path';
17
+ import { noBundleError, planDev } from '../utils/dev-plan.js';
18
+ import { buildAppForRuntime, EXAMPLE_RUNTIMES } from '../utils/runtimes.js';
19
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
20
+ function readPackage(cwd) {
21
+ try {
22
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
23
+ }
24
+ catch {
25
+ // A missing or malformed package.json is not this command's error to
26
+ // report: `planDev` refuses right after, naming the flags that make
27
+ // `gjsify dev` work in a directory with no manifest at all.
28
+ return {};
29
+ }
30
+ }
31
+ export const devCommand = {
32
+ command: 'dev [entry]',
33
+ description: 'Watch this project, rebuild on change and relaunch the app.',
34
+ builder: (yargs) => yargs
35
+ .positional('entry', {
36
+ description: 'Entry point to build (default: the one the build script names, e.g. src/index.ts from `build:gjs`).',
37
+ type: 'string',
38
+ })
39
+ .option('runtime', {
40
+ description: 'Runtime to build for and launch on: gjs | node | bun | deno (default: the host runtime). ' +
41
+ 'node/bun/deno build the same `--app node` bundle.',
42
+ type: 'string',
43
+ choices: EXAMPLE_RUNTIMES,
44
+ // No yargs `default` — the host default is applied in the
45
+ // handler, so `hostRuntime()` is evaluated where it can be seen.
46
+ defaultDescription: 'host runtime (gjs on gjs, else node/bun/deno)',
47
+ })
48
+ .option('script', {
49
+ description: 'package.json script the build flags are read from (default: build:gjs, or build:node for node/bun/deno).',
50
+ type: 'string',
51
+ })
52
+ .option('globals', { description: 'Override the build script `--globals` value.', type: 'string' })
53
+ .option('outfile', { description: 'Override the build script `--outfile` path.', type: 'string' })
54
+ .option('watch-dir', {
55
+ description: 'Directory watched recursively (default: the directory of the entry point).',
56
+ type: 'string',
57
+ })
58
+ .option('debounce', {
59
+ description: 'Quiet window in ms after a change before rebuilding.',
60
+ type: 'number',
61
+ default: 200,
62
+ })
63
+ .option('build-only', {
64
+ description: 'Rebuild on every change but never launch the app.',
65
+ type: 'boolean',
66
+ default: false,
67
+ }),
68
+ handler: async (args) => {
69
+ const cwd = process.cwd();
70
+ const pkg = readPackage(cwd);
71
+ // The cast, not a guard: yargs' `choices` above already rejects anything
72
+ // outside the four, and `hostRuntime()` returns exactly that union — a
73
+ // second check here would be validating our own vocabulary.
74
+ const runtime = (args.runtime ?? hostRuntime());
75
+ const app = buildAppForRuntime(runtime);
76
+ const scriptName = args.script ?? (app === 'gjs' ? 'build:gjs' : 'build:node');
77
+ let plan;
78
+ try {
79
+ plan = planDev({
80
+ scripts: pkg.scripts ?? {},
81
+ scriptName,
82
+ app,
83
+ declaredBundle: app === 'gjs' ? pkg.gjsify?.main : pkg.gjsify?.example?.node,
84
+ entry: args.entry,
85
+ globals: args.globals,
86
+ outfile: args.outfile,
87
+ watchDir: args.watchDir,
88
+ });
89
+ }
90
+ catch (err) {
91
+ console.error(err.message);
92
+ // `return process.exit()`: under GJS a bare `process.exit()` only
93
+ // SCHEDULES the exit and returns, so execution would fall through
94
+ // into the watch loop and the deferred exit would never carry its
95
+ // code. Same trap as `showcase.ts` / `storybook.ts`.
96
+ return process.exit(1);
97
+ }
98
+ const bundlePath = plan.bundle === undefined ? undefined : resolve(cwd, plan.bundle);
99
+ if (bundlePath === undefined && !args.buildOnly) {
100
+ console.error(noBundleError(scriptName, app).message);
101
+ return process.exit(1);
102
+ }
103
+ const watchDir = resolve(cwd, plan.watchDir);
104
+ if (!existsSync(watchDir)) {
105
+ // `fs.watch` on a missing directory throws a bare ENOENT with the
106
+ // path buried in a stack — say which directory was DERIVED and how
107
+ // to override it instead.
108
+ console.error(`gjsify dev: nothing to watch — ${relative(cwd, watchDir) || watchDir} does not exist.\n` +
109
+ ` Point the loop at your sources: gjsify dev --watch-dir <dir>`);
110
+ return process.exit(1);
111
+ }
112
+ const target = bundlePath === undefined ? `\`${scriptName}\` output` : relative(cwd, bundlePath);
113
+ console.log(`Watching ${relative(cwd, watchDir) || '.'} → ${target} (runtime: ${runtime})`);
114
+ // In-process build — runtime-agnostic (Node uses npm rolldown, GJS the
115
+ // @gjsify/rolldown-native facade), no re-spawn of the CLI binary.
116
+ const { runCli } = await import('../cli-app.js');
117
+ const { runWatchLoop, spawnBundleSupervised } = await import('../utils/watch-loop.js');
118
+ await runWatchLoop({
119
+ dir: watchDir,
120
+ dirLabel: relative(cwd, watchDir) || '.',
121
+ label: 'dev',
122
+ build: () => runCli(plan.buildArgv),
123
+ spawnChild: args.buildOnly || bundlePath === undefined
124
+ ? null
125
+ : () => spawnBundleSupervised(runtime, bundlePath, cwd, 'this project'),
126
+ // Passed even under `--build-only`: what makes the loop feed itself
127
+ // is the WRITE, not the launch.
128
+ output: bundlePath,
129
+ debounceMs: args.debounce,
130
+ });
131
+ },
132
+ };
@@ -100,7 +100,7 @@ export const dlxCommand = {
100
100
  }
101
101
  // Terminal call — exit on success, or the GJS main loop parks this
102
102
  // process forever (see RunGjsBundleOptions.exitOnSuccess).
103
- await runGjsBundle(entry.bundlePath, extraArgs, { exitOnSuccess: true });
103
+ await runGjsBundle(entry.bundlePath, extraArgs, { completion: 'exit', exitOnSuccess: true });
104
104
  },
105
105
  };
106
106
  /**
@@ -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';
@@ -35,5 +36,6 @@ export * from './barrels.js';
35
36
  export * from './tsc.js';
36
37
  export * from './affected.js';
37
38
  export * from './storybook.js';
39
+ export * from './dev.js';
38
40
  export * from './debug.js';
39
41
  export * from './browse.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';
@@ -35,5 +36,6 @@ export * from './barrels.js';
35
36
  export * from './tsc.js';
36
37
  export * from './affected.js';
37
38
  export * from './storybook.js';
39
+ export * from './dev.js';
38
40
  export * from './debug.js';
39
41
  export * from './browse.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 {};