@gjsify/cli 0.41.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 (58) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1040 -405
  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 +2 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/debug.js +1 -1
  9. package/lib/commands/dev.d.ts +13 -0
  10. package/lib/commands/dev.js +132 -0
  11. package/lib/commands/dlx.js +1 -1
  12. package/lib/commands/index.d.ts +1 -0
  13. package/lib/commands/index.js +1 -0
  14. package/lib/commands/run.js +38 -85
  15. package/lib/commands/ship.js +6 -1
  16. package/lib/commands/showcase.js +1 -1
  17. package/lib/commands/storybook.js +17 -74
  18. package/lib/commands/test.js +12 -16
  19. package/lib/commands/tsc.js +96 -45
  20. package/lib/config.js +4 -0
  21. package/lib/types/config-data.d.ts +27 -0
  22. package/lib/utils/app-metadata.js +27 -6
  23. package/lib/utils/check-system-deps.js +49 -7
  24. package/lib/utils/daemon-command.d.ts +7 -0
  25. package/lib/utils/daemon-command.js +30 -0
  26. package/lib/utils/dev-plan.d.ts +52 -0
  27. package/lib/utils/dev-plan.js +117 -0
  28. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  29. package/lib/utils/gi-runtime-paths.js +84 -0
  30. package/lib/utils/gi-typelib.d.ts +49 -0
  31. package/lib/utils/gi-typelib.js +160 -0
  32. package/lib/utils/node-script.d.ts +4 -1
  33. package/lib/utils/node-script.js +27 -3
  34. package/lib/utils/npm-oidc.js +8 -5
  35. package/lib/utils/run-gjs.d.ts +12 -1
  36. package/lib/utils/run-gjs.js +24 -31
  37. package/lib/utils/run-node.d.ts +9 -4
  38. package/lib/utils/run-node.js +34 -41
  39. package/lib/utils/ship/depends.d.ts +5 -0
  40. package/lib/utils/ship/depends.js +20 -1
  41. package/lib/utils/ship/discover.js +106 -1
  42. package/lib/utils/ship/launcher.js +27 -4
  43. package/lib/utils/ship/payload.d.ts +35 -0
  44. package/lib/utils/ship/payload.js +84 -0
  45. package/lib/utils/ship/plan.js +20 -0
  46. package/lib/utils/ship/settings.d.ts +8 -1
  47. package/lib/utils/ship/settings.js +13 -1
  48. package/lib/utils/ship/types.d.ts +19 -0
  49. package/lib/utils/simple-command.d.ts +16 -0
  50. package/lib/utils/simple-command.js +77 -0
  51. package/lib/utils/spawn.d.ts +7 -1
  52. package/lib/utils/spawn.js +34 -7
  53. package/lib/utils/system-gi.d.ts +24 -0
  54. package/lib/utils/system-gi.js +14 -3
  55. package/lib/utils/watch-loop.d.ts +91 -0
  56. package/lib/utils/watch-loop.js +207 -0
  57. package/package.json +19 -18
  58. 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, 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';
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';
@@ -129,6 +129,7 @@ export async function runCli(argv) {
129
129
  .command(tsc.command, tsc.description, tsc.builder, tsc.handler)
130
130
  .command(affected.command, affected.description, affected.builder, affected.handler)
131
131
  .command(storybook.command, storybook.description, storybook.builder, storybook.handler)
132
+ .command(dev.command, dev.description, dev.builder, dev.handler)
132
133
  .command(debug.command, debug.description, debug.builder, debug.handler)
133
134
  .command(browse.command, browse.description, browse.builder, browse.handler)
134
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
  };
@@ -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
  /**
@@ -36,5 +36,6 @@ export * from './barrels.js';
36
36
  export * from './tsc.js';
37
37
  export * from './affected.js';
38
38
  export * from './storybook.js';
39
+ export * from './dev.js';
39
40
  export * from './debug.js';
40
41
  export * from './browse.js';
@@ -36,5 +36,6 @@ export * from './barrels.js';
36
36
  export * from './tsc.js';
37
37
  export * from './affected.js';
38
38
  export * from './storybook.js';
39
+ export * from './dev.js';
39
40
  export * from './debug.js';
40
41
  export * from './browse.js';
@@ -15,13 +15,15 @@
15
15
  // to disambiguate can pass `./<file>` explicitly.
16
16
  import { readFileSync, statSync } from 'node:fs';
17
17
  import { basename, delimiter, join, resolve } from 'node:path';
18
- import { spawn } from 'node:child_process';
18
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { runRuntimeBundle } from '../utils/run-node.js';
21
21
  import { readPackageJson } from '../utils/pkg-json-edit.js';
22
22
  import { runNodeScript } from '../utils/node-script.js';
23
23
  import { nodeShimDir } from '../utils/gjsify-shim.js';
24
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
+ import { gjsifyCommandArgv } from '../utils/simple-command.js';
26
+ import { isDaemonCommand } from '../utils/daemon-command.js';
25
27
  import { discoverWorkspaces } from '@gjsify/workspace';
26
28
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
27
29
  import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, } from '../utils/runtimes.js';
@@ -124,7 +126,7 @@ export const runCommand = {
124
126
  console.error(`gjsify run: --node-script cannot be combined with ${clash}.`);
125
127
  return process.exit(1);
126
128
  }
127
- await runNodeScript(target, extraArgs, { exitOnSuccess: true });
129
+ await runNodeScript(target, extraArgs, { completion: 'exit', exitOnSuccess: true });
128
130
  return;
129
131
  }
130
132
  // `--runtime <gjs|node|bun|deno>` (explicit) → run a BUNDLE FILE on the
@@ -174,10 +176,10 @@ export const runCommand = {
174
176
  // Terminal calls — exit on success, or the GJS main loop parks
175
177
  // this process forever (see RunGjsBundleOptions.exitOnSuccess).
176
178
  if (runtime === 'gjs') {
177
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
179
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
178
180
  }
179
181
  else {
180
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
182
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
181
183
  }
182
184
  return;
183
185
  }
@@ -209,10 +211,10 @@ async function runTargetOnRuntime(runtime, target, extraArgs) {
209
211
  const file = resolve(target);
210
212
  // Terminal call — exit on success (the GJS/GLib loop would otherwise park).
211
213
  if (runtime === 'gjs') {
212
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
214
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
213
215
  }
214
216
  else {
215
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
217
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
216
218
  }
217
219
  }
218
220
  /**
@@ -420,6 +422,16 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
420
422
  console.error(err.message);
421
423
  dispatchFailed = true;
422
424
  }
425
+ // A watch loop RESOLVES as soon as it is armed and then keeps running,
426
+ // so its resolution is not an ending to exit on: `gjsify run dev` used
427
+ // to take down the loop it had just started, the instant the first
428
+ // build+launch finished. Node never saw it — there the same script takes
429
+ // the shell-spawn path below — and it is the DOCUMENTED entry point:
430
+ // `gjsify create` prints `gjsify run dev`, and every template's `dev`
431
+ // script is a bare `gjsify dev`. Returning leaves the process on the
432
+ // main loop `runWatchLoop` holds, where Ctrl+C ends it.
433
+ if (!dispatchFailed && isDaemonCommand())
434
+ return;
423
435
  // A thrown command wins over `process.exitCode`; otherwise honour a
424
436
  // non-zero code the handler set instead of throwing (e.g. a failing
425
437
  // check) — a bare exit(0) here masked those as success too.
@@ -436,23 +448,23 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
436
448
  return process.exit(dispatchCode);
437
449
  }
438
450
  const fullCmd = extraArgs.length > 0 ? `${literal} ${extraArgs.map(shellEscape).join(' ')}` : literal;
439
- // ensureMainLoop() (called inside spawn) keeps GJS alive after the
440
- // child exits — without an explicit process.exit() the success path
441
- // would park the loop forever. The error path already exits.
442
- await new Promise((resolveOk, reject) => {
443
- const child = spawn(fullCmd, [], { cwd, env, stdio: 'inherit', shell: true });
444
- child.on('close', (code) => {
445
- if (code === 0)
446
- resolveOk();
447
- else
448
- reject(new Error(`script "${script}" exited with code ${code}`));
449
- });
450
- child.on('error', reject);
451
- }).catch((err) => {
451
+ // `completion: 'exit'` — ensureMainLoop() (called inside the async spawn) keeps
452
+ // GJS alive after the child exits, and both paths below end in `process.exit`,
453
+ // which is what tears it down. `shell: true` deliberately skips the win32
454
+ // rewrite: this is a command LINE, and Node routes it through %COMSPEC%.
455
+ let result;
456
+ try {
457
+ result = await spawnToCompletion(fullCmd, [], { completion: 'exit', cwd, env, shell: true });
458
+ }
459
+ catch (err) {
452
460
  console.error(err.message);
453
- process.exit(1);
454
- });
455
- process.exit(0);
461
+ return process.exit(1);
462
+ }
463
+ if (result.code !== 0) {
464
+ console.error(`script "${script}" exited with ${describeExit(result)}`);
465
+ return process.exit(1);
466
+ }
467
+ return process.exit(0);
456
468
  }
457
469
  function shellEscape(arg) {
458
470
  if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
@@ -463,70 +475,11 @@ function shellEscape(arg) {
463
475
  * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
464
476
  * operators / substitutions / unquoted globs, return the argv to feed `runCli`
465
477
  * (the subcommand + its args, with `extraArgs` appended). Otherwise null — the
466
- * caller falls back to the shell spawn path. Quotes are honoured so a quoted
467
- * glob (`'src/**'`) survives as a single literal token, exactly as the shell
468
- * would hand it to `gjsify` (which does its own glob expansion).
478
+ * caller falls back to the shell spawn path.
469
479
  */
470
480
  function gjsifyInProcessArgv(literal, extraArgs) {
471
- const tokens = tokenizeSimpleCommand(literal);
472
- if (!tokens || tokens.length < 2 || tokens[0] !== 'gjsify')
481
+ const argv = gjsifyCommandArgv(literal);
482
+ if (!argv)
473
483
  return null;
474
- return [...tokens.slice(1), ...extraArgs];
475
- }
476
- /**
477
- * Quote-aware tokenizer for a SIMPLE command line (no shell features beyond
478
- * single/double quoting). Returns the tokens, or null if the string contains
479
- * anything the shell would treat specially — operators (`&& | ; < > &`),
480
- * substitutions (`$(...)` / backticks / `$VAR`), unquoted globs/expansions
481
- * (`* ? { } [ ] ~`), comments (`#`), or an unterminated quote — in which case
482
- * the caller MUST use the real shell instead.
483
- */
484
- function tokenizeSimpleCommand(cmd) {
485
- const tokens = [];
486
- let cur = '';
487
- let has = false;
488
- let quote = null;
489
- for (let i = 0; i < cmd.length; i++) {
490
- const c = cmd[i];
491
- if (quote === "'") {
492
- if (c === "'")
493
- quote = null;
494
- else
495
- cur += c;
496
- continue;
497
- }
498
- if (quote === '"') {
499
- if (c === '"')
500
- quote = null;
501
- else if (c === '\\' && (cmd[i + 1] === '"' || cmd[i + 1] === '\\'))
502
- cur += cmd[++i];
503
- else if (c === '$' || c === '`')
504
- return null; // substitution inside "…"
505
- else
506
- cur += c;
507
- continue;
508
- }
509
- if (c === "'" || c === '"') {
510
- quote = c;
511
- has = true;
512
- continue;
513
- }
514
- if (c === ' ' || c === '\t') {
515
- if (has) {
516
- tokens.push(cur);
517
- cur = '';
518
- has = false;
519
- }
520
- continue;
521
- }
522
- if ('|&;<>`$()\\\n\r*?{}[]~#!'.includes(c))
523
- return null;
524
- cur += c;
525
- has = true;
526
- }
527
- if (quote)
528
- return null; // unterminated quote
529
- if (has)
530
- tokens.push(cur);
531
- return tokens;
484
+ return [...argv, ...extraArgs];
532
485
  }
@@ -20,7 +20,7 @@ import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
20
20
  import { discoverPayload } from '../utils/ship/discover.js';
21
21
  import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
22
22
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
23
- import { buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
23
+ import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
24
24
  import { planOverlay, planStage } from '../utils/ship/plan.js';
25
25
  import { renderLauncher } from '../utils/ship/launcher.js';
26
26
  import { buildRpm } from '../utils/ship/rpm.js';
@@ -151,10 +151,15 @@ async function packOne(input) {
151
151
  hasSchemas: settings.schemaFiles.length > 0,
152
152
  extra: settings.extraDepends[format.id],
153
153
  typelibPackages: settings.typelibPackages,
154
+ bundledTypelibs: settings.typelibFiles,
154
155
  minGjsVersion: settings.minGjsVersion,
155
156
  });
156
157
  for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
157
158
  console.warn(`${LOG} ${warning}`);
159
+ // Before the label is chosen, not after: `archName` collapses an
160
+ // arch-independent payload to `all`/`noarch`, and a mismatch only matters
161
+ // for a payload that IS architecture-specific.
162
+ assertPayloadMatchesArch(payload, settings.arch);
158
163
  const archLabel = format.archName(settings.arch, isArchIndependent(payload));
159
164
  const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
160
165
  const bytes = format.id === 'deb' ? await buildDeb(common) : await buildRpm(common);
@@ -411,7 +411,7 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
411
411
  // thrown: an exception escaping the handler makes yargs dump the command's
412
412
  // full `--help` above the message, which buries it.
413
413
  try {
414
- await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
414
+ await runRuntimeBundle(runtime, entry, [], { completion: 'exit', exitOnSuccess: true });
415
415
  }
416
416
  catch (err) {
417
417
  console.error(`${err.message}\n\n` +