@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
@@ -0,0 +1,77 @@
1
+ // Quote-aware tokenizer for a package.json script that is a SIMPLE command line.
2
+ //
3
+ // Lifted out of `commands/run.ts` when `gjsify dev` became the second caller:
4
+ // both need to know whether a declared script is a plain `gjsify <subcommand> …`
5
+ // they may take apart, or something only a shell can execute. The predicate is
6
+ // deliberately conservative in ONE direction — a `null` costs the caller its
7
+ // fast path, a wrong token list would run a different command than the script
8
+ // says.
9
+ /**
10
+ * Tokenize `cmd`, or return `null` if the string contains anything the shell
11
+ * would treat specially — operators (`&& | ; < > &`), substitutions (`$(...)` /
12
+ * backticks / `$VAR`), unquoted globs/expansions (`* ? { } [ ] ~`), comments
13
+ * (`#`), or an unterminated quote — in which case the caller MUST use the real
14
+ * shell instead. Quotes are honoured so a quoted glob (`'src/**'`) survives as a
15
+ * single literal token, exactly as the shell would hand it to `gjsify` (which
16
+ * does its own glob expansion).
17
+ */
18
+ export function tokenizeSimpleCommand(cmd) {
19
+ const tokens = [];
20
+ let cur = '';
21
+ let has = false;
22
+ let quote = null;
23
+ for (let i = 0; i < cmd.length; i++) {
24
+ const c = cmd[i];
25
+ if (quote === "'") {
26
+ if (c === "'")
27
+ quote = null;
28
+ else
29
+ cur += c;
30
+ continue;
31
+ }
32
+ if (quote === '"') {
33
+ if (c === '"')
34
+ quote = null;
35
+ else if (c === '\\' && (cmd[i + 1] === '"' || cmd[i + 1] === '\\'))
36
+ cur += cmd[++i];
37
+ else if (c === '$' || c === '`')
38
+ return null; // substitution inside "…"
39
+ else
40
+ cur += c;
41
+ continue;
42
+ }
43
+ if (c === "'" || c === '"') {
44
+ quote = c;
45
+ has = true;
46
+ continue;
47
+ }
48
+ if (c === ' ' || c === '\t') {
49
+ if (has) {
50
+ tokens.push(cur);
51
+ cur = '';
52
+ has = false;
53
+ }
54
+ continue;
55
+ }
56
+ if ('|&;<>`$()\\\n\r*?{}[]~#!'.includes(c))
57
+ return null;
58
+ cur += c;
59
+ has = true;
60
+ }
61
+ if (quote)
62
+ return null; // unterminated quote
63
+ if (has)
64
+ tokens.push(cur);
65
+ return tokens;
66
+ }
67
+ /**
68
+ * The argv of a script that is a single `gjsify <subcommand> …` command — i.e.
69
+ * the tokens after `gjsify` — or `null` when it is anything else. `null` is the
70
+ * signal to hand the literal to a shell.
71
+ */
72
+ export function gjsifyCommandArgv(literal) {
73
+ const tokens = tokenizeSimpleCommand(literal);
74
+ if (!tokens || tokens.length < 2 || tokens[0] !== 'gjsify')
75
+ return null;
76
+ return tokens.slice(1);
77
+ }
@@ -6,10 +6,16 @@ import type { ChildProcess } from 'node:child_process';
6
6
  * main loop, so the streaming async `spawn` is safe on both hosts.
7
7
  * - `'return'` — the command returns and lets the CLI unwind. Under GJS that must
8
8
  * not leave a main loop armed, so the child runs on the blocking `spawnSync` path.
9
+ * - `'daemon'` — the command settles nothing: it stays alive supervising the child,
10
+ * on a main loop it holds ITSELF under GJS (`holdMainLoop()`; see the module header
11
+ * for why this spawn cannot arm one for it). The caller takes the handle through
12
+ * {@link SpawnToCompletionOptions.onSpawn} and does NOT await the returned promise,
13
+ * so "once this spawn settles" has no answer for it. Distinct from `'exit'` because
14
+ * a supervisor has no exit to be safe by; it routes to the same streaming path.
9
15
  *
10
16
  * See the module header for the full rationale.
11
17
  */
12
- export type SpawnCompletionContract = 'exit' | 'return';
18
+ export type SpawnCompletionContract = 'exit' | 'return' | 'daemon';
13
19
  export interface SpawnToCompletionOptions {
14
20
  /** The caller's teardown contract. Required — see {@link SpawnCompletionContract}. */
15
21
  completion: SpawnCompletionContract;
@@ -24,6 +24,16 @@
24
24
  // | Node | anything | async `spawn` (streaming) |
25
25
  // | GJS | `process.exit(…)` | async `spawn` (streaming) |
26
26
  // | GJS | a normal `return` | blocking `spawnSync` (captured) |
27
+ // | GJS | never — it supervises | async `spawn` (streaming) |
28
+ //
29
+ // The fourth row is `completion: 'daemon'`: a command like `gjsify storybook
30
+ // --watch` neither exits nor unwinds, so an armed loop is not a leak there but the
31
+ // thing keeping it alive. It takes the handle through `onSpawn` and never awaits the
32
+ // completion promise. What it must NOT do is expect this spawn to arm that loop —
33
+ // `ensureMainLoop()` declines at `GLib.main_depth() !== 0`, and a supervisor reaches
34
+ // its spawn from a continuation GJS resumed inside the main-context spin it runs
35
+ // while the entry module's top-level await is pending, i.e. at depth 1. A daemon
36
+ // holds the loop itself, through `holdMainLoop()` (`utils/watch-loop.ts` does).
27
37
  //
28
38
  // which is why `gjsify foreach`/`workspace`/`check` and `runGjsBundle` run hundreds
29
39
  // of async-spawned children under GJS without hanging: each ends its handler with
@@ -31,19 +41,36 @@
31
41
  // REQUIRED {@link SpawnToCompletionOptions.completion} field — a new call site must
32
42
  // state which side of the table it is on rather than inherit a wrong default.
33
43
  //
34
- // ## When a raw `node:child_process` spawn is still fine
44
+ // ## Under `packages/infra/cli/src/`, a raw async `spawn` is never fine
45
+ //
46
+ // The contract above binds only code that USES this helper, and one import bypassed
47
+ // it for years — `utils/run-lifecycle-script.ts` reached for `node:child_process`
48
+ // while its own header asserted the opposite, and `gjsify pack` parked for 5m30s
49
+ // after finishing its work on any package with a `prepack`, the production
50
+ // `gjsify run publish:app` chain included (#1010).
51
+ //
52
+ // So the rule is enforced rather than written down: `scripts/check-spawn-teardown-
53
+ // contract.mjs` fails on a value import of async `spawn` from `node:child_process`
54
+ // anywhere under `packages/infra/cli/src/` outside this file, unless the site
55
+ // carries an entry in `scripts/spawn-teardown-exceptions.mjs`. Deliberately narrow
56
+ // to async `spawn`: `spawnSync` blocks and arms nothing, and `exec`/`execFile` stay
57
+ // out until the `gjsify gsettings` observation (an `execFile` command that compiles
58
+ // a schema and exits in 0.45 s although `_execImpl` also calls `ensureMainLoop`) is
59
+ // explained rather than generalised from.
60
+ //
61
+ // ## What is spawned still matters — the Windows rewrite
35
62
  //
36
- // This helper also carries the Windows command rewrite (`forWin32`), so the rule is
37
- // about WHAT is spawned:
63
+ // This helper also carries the Windows command rewrite (`forWin32`), which is a
64
+ // second reason to come through it, and the one that applies OUTSIDE the CLI too:
38
65
  //
39
66
  // • a real executable (`git`, `gh`, `node`, `gjs`, `flatpak`, `process.execPath`)
40
- // may use `node:child_process` directly — Windows resolves those via `.exe`.
67
+ // resolves on Windows via `.exe`, so a rewrite is not what it needs.
41
68
  // • `shell: true` is fine — Node routes it through `%COMSPEC%`, which finds
42
69
  // `.cmd` shims.
43
70
  // • a PACKAGE MANAGER or any `node_modules/.bin` entry by BARE NAME (`npm`,
44
- // `yarn`, `npx`, `tsc`, `oxlint`, a `pm` from `detectPackageManager()`) MUST
45
- // come through here: on Windows those are `.cmd` shims, where `spawn('npm')` is
46
- // ENOENT and `spawn('npm.cmd')` is EINVAL, so a raw spawn cannot work at all.
71
+ // `yarn`, `npx`, `tsc`, `oxlint`, a `pm` from `detectPackageManager()`) cannot
72
+ // work without it: on Windows those are `.cmd` shims, where `spawn('npm')` is
73
+ // ENOENT and `spawn('npm.cmd')` is EINVAL.
47
74
  //
48
75
  // The third case is not hypothetical, and each instance was invisible to Linux and
49
76
  // macOS CI: raw spawns left `commands/check.ts` exiting 1 with no diagnostic,
@@ -9,6 +9,30 @@ export interface SystemGiOptions {
9
9
  /** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
10
10
  searchDirs?: (env: Record<string, string | undefined>) => string[];
11
11
  }
12
+ /**
13
+ * Prefixes a macOS GI stack is installed under in practice — probed, never
14
+ * assumed: each is accepted only when it actually holds a `girepository-1.0/`.
15
+ *
16
+ * Load-bearing rather than belt-and-braces: `brew install gtk4` installs RUNTIME
17
+ * dependencies only, so a user running a published showcase routinely has the
18
+ * whole GTK stack and no `pkg-config` at all (a BUILD dependency of those
19
+ * formulae). The pkg-config source below is the general mechanism — it finds a
20
+ * jhbuild or otherwise bespoke prefix — but it cannot be the only one.
21
+ *
22
+ * Exported because a `--app gjs` BUNDLE has to carry the candidates rather than this
23
+ * function's answer: it runs on a host the build machine never saw, so the probe moves
24
+ * into the emitted prologue (`gi-runtime-paths.ts`). `@gjsify/utils`' copy of this rule
25
+ * exports it too; node-gi's does not, and the mirror suite compares OUTPUTS, not surface.
26
+ */
27
+ export declare const PROBED_GI_LIBDIRS: Record<string, readonly string[]>;
28
+ /**
29
+ * The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API.
30
+ *
31
+ * Exported so `gi-typelib.ts` shares it rather than keeping a third copy of the
32
+ * one string this whole layout rule turns on. Both mirrors export it, so the
33
+ * agreement suite still compares two files that differ only in language.
34
+ */
35
+ export declare const TYPELIB_SUBDIR = "girepository-1.0";
12
36
  /** Split an OS search-path variable, dropping empty segments. */
13
37
  export declare function splitSearchPath(value: string | undefined, separator?: string): string[];
14
38
  /**
@@ -62,12 +62,23 @@ const { basename, dirname, join } = posix;
62
62
  * whole GTK stack and no `pkg-config` at all (a BUILD dependency of those
63
63
  * formulae). The pkg-config source below is the general mechanism — it finds a
64
64
  * jhbuild or otherwise bespoke prefix — but it cannot be the only one.
65
+ *
66
+ * Exported because a `--app gjs` BUNDLE has to carry the candidates rather than this
67
+ * function's answer: it runs on a host the build machine never saw, so the probe moves
68
+ * into the emitted prologue (`gi-runtime-paths.ts`). `@gjsify/utils`' copy of this rule
69
+ * exports it too; node-gi's does not, and the mirror suite compares OUTPUTS, not surface.
65
70
  */
66
- const PROBED_GI_LIBDIRS = {
71
+ export const PROBED_GI_LIBDIRS = {
67
72
  darwin: ['/opt/homebrew/lib', '/usr/local/lib', '/opt/local/lib'],
68
73
  };
69
- /** The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API. */
70
- const TYPELIB_SUBDIR = 'girepository-1.0';
74
+ /**
75
+ * The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API.
76
+ *
77
+ * Exported so `gi-typelib.ts` shares it rather than keeping a third copy of the
78
+ * one string this whole layout rule turns on. Both mirrors export it, so the
79
+ * agreement suite still compares two files that differ only in language.
80
+ */
81
+ export const TYPELIB_SUBDIR = 'girepository-1.0';
71
82
  /** Memoized `pkg-config` answer: the spawn happens at most once per process. */
72
83
  let pkgConfigDirsCache = null;
73
84
  /** Split an OS search-path variable, dropping empty segments. */
@@ -0,0 +1,91 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ import { type ExampleRuntime } from './runtimes.js';
3
+ /**
4
+ * Start a child the caller SUPERVISES rather than waits for.
5
+ *
6
+ * `completion: 'daemon'` is the third row of the teardown table: a watch loop
7
+ * neither exits nor unwinds, so the armed GJS main loop is not a leak here —
8
+ * it is what keeps the session alive. The handle arrives synchronously through
9
+ * `onSpawn`; the completion promise is deliberately never awaited (it settles
10
+ * when the child is killed for the next rebuild), only its REJECTION is routed
11
+ * out, so a missing interpreter still surfaces as a rebuild failure.
12
+ */
13
+ export declare function spawnSupervised(cmd: string, cmdArgs: string[], env?: NodeJS.ProcessEnv): Promise<ChildProcess>;
14
+ /**
15
+ * Launch a built bundle as a supervised child on `runtime`, wired the way
16
+ * `gjsify run` would launch it for real: `gjs -m` for the `--app gjs` bundle,
17
+ * the `--app node` bundle on node/bun/deno, and in BOTH cases the native
18
+ * typelib/library environment for any `@gjsify` native package the bundle
19
+ * reaches. The env is not optional on the gjs arm either — a watch loop that
20
+ * skipped it would run an app that `gjsify run` can start and this cannot.
21
+ *
22
+ * `subject` names what is being run in the node-gi diagnostic ("the storybook",
23
+ * "this project").
24
+ */
25
+ export declare function spawnBundleSupervised(runtime: ExampleRuntime, bundlePath: string, cwd: string, subject: string): Promise<ChildProcess>;
26
+ export interface WatchLoopOptions {
27
+ /** Directory watched recursively. */
28
+ dir: string;
29
+ /** How `dir` is spelled in the loop's own log lines. Defaults to `dir`. */
30
+ dirLabel?: string;
31
+ /** Prefix for the loop's log lines: `[<label>] …`. */
32
+ label: string;
33
+ /** Regenerate derived inputs before each build (e.g. a generated entry). */
34
+ prepare?: () => void | Promise<void>;
35
+ /** Build the bundle. Runtime-agnostic — an in-process re-entry of `build`. */
36
+ build: () => Promise<void>;
37
+ /** Launch the freshly built bundle, or `null` to rebuild without launching. */
38
+ spawnChild: (() => Promise<ChildProcess>) | null;
39
+ /**
40
+ * Absolute path the build WRITES, when one is known. Changes to it never
41
+ * trigger a rebuild — see {@link isSelfWrite} for why the loop otherwise
42
+ * feeds itself.
43
+ */
44
+ output?: string;
45
+ /** Quiet window after a change before rebuilding. */
46
+ debounceMs?: number;
47
+ }
48
+ /**
49
+ * Whether a watcher event is the loop's OWN output rather than a source edit.
50
+ *
51
+ * Without this the loop feeds itself whenever the bundle lands inside the
52
+ * watched tree, which is the DEFAULT arrangement rather than an exotic one: the
53
+ * watch dir defaults to the entry point's directory, so `gjsify dev index.ts` on
54
+ * a project building to `dist/` watches the project root and every build it runs
55
+ * is a change under it. Measured before this filter: one edit to `index.ts`
56
+ * started a rebuild every ~5 s that never stopped.
57
+ *
58
+ * Two shapes, because a build writes more than the file it is named after:
59
+ *
60
+ * - the bundle itself and anything sharing its stem (`out.mjs.map`), which the
61
+ * bundler writes beside it in the same pass;
62
+ * - the whole output DIRECTORY — but only when it is not the watched directory
63
+ * itself. `--outfile dist/out.mjs` under a watch dir of `.` puts chunks and
64
+ * assets there too; an `--outfile` written straight into the watched dir has
65
+ * no directory to exclude that would not also exclude the sources.
66
+ *
67
+ * `filename` is what `fs.watch` reports, relative to `dir` — `null` on the
68
+ * platforms that cannot name the changed path, where the honest answer is "no
69
+ * idea", i.e. rebuild.
70
+ *
71
+ * BOTH operands are resolved before they are compared. Resolving only the
72
+ * changed path is right on POSIX for as long as every caller happens to pass an
73
+ * already-absolute `output`, and silently wrong the moment one does not: on
74
+ * win32 `resolve()` also rewrites the separators and picks up the drive, so the
75
+ * two sides stop being the same spelling of the same file and every self-write
76
+ * reads as a source edit — which is the feedback loop this filter exists to
77
+ * stop. Caught by the win32 leg; on Linux all five cases passed.
78
+ */
79
+ export declare function isSelfWrite(dir: string, output: string | undefined, filename: string | null): boolean;
80
+ /**
81
+ * Run the loop: build + launch once, then rebuild + relaunch on every change
82
+ * under `dir`. Returns once the watcher is armed; the process is then held open
83
+ * — by the watcher handle on Node, by the GLib main loop on GJS — so there is
84
+ * nothing left to await. That the RESOLUTION is not completion is the one thing
85
+ * a caller has to know, which is why it is also declared through
86
+ * `markDaemonCommand()` for the callers that cannot see this signature.
87
+ *
88
+ * A build failure is reported and the loop KEEPS WATCHING: the next save is the
89
+ * fix, and a dev loop that dies on a syntax error is a dev loop nobody uses.
90
+ */
91
+ export declare function runWatchLoop(opts: WatchLoopOptions): Promise<void>;
@@ -0,0 +1,207 @@
1
+ // The watch → rebuild → relaunch loop, shared by `gjsify storybook --watch` and
2
+ // `gjsify dev`.
3
+ //
4
+ // It lives here rather than in either command because it is the whole Node-free
5
+ // inner development cycle and `gjsify build --watch` cannot be it: rolldown's
6
+ // watcher API is npm-engine-only (`bundler-pick.ts` refuses under
7
+ // `@gjsify/rolldown-native`), so the one path that watches is the one running on
8
+ // the host that does not need it. This loop asks the bundler for no watcher at
9
+ // all — `fs.watch` reports the change and the rebuild is an in-process re-entry
10
+ // of the ordinary build command, which is runtime-agnostic.
11
+ import { watch } from 'node:fs';
12
+ import { dirname, resolve, sep } from 'node:path';
13
+ import { holdMainLoop } from '@gjsify/utils/main-loop';
14
+ import { markDaemonCommand } from './daemon-command.js';
15
+ import { spawnToCompletion } from './spawn.js';
16
+ import { RUNTIMES } from './runtimes.js';
17
+ /**
18
+ * Start a child the caller SUPERVISES rather than waits for.
19
+ *
20
+ * `completion: 'daemon'` is the third row of the teardown table: a watch loop
21
+ * neither exits nor unwinds, so the armed GJS main loop is not a leak here —
22
+ * it is what keeps the session alive. The handle arrives synchronously through
23
+ * `onSpawn`; the completion promise is deliberately never awaited (it settles
24
+ * when the child is killed for the next rebuild), only its REJECTION is routed
25
+ * out, so a missing interpreter still surfaces as a rebuild failure.
26
+ */
27
+ export function spawnSupervised(cmd, cmdArgs, env) {
28
+ return new Promise((resolveChild, reject) => {
29
+ void spawnToCompletion(cmd, cmdArgs, { completion: 'daemon', env, onSpawn: resolveChild }).catch(reject);
30
+ });
31
+ }
32
+ /**
33
+ * Launch a built bundle as a supervised child on `runtime`, wired the way
34
+ * `gjsify run` would launch it for real: `gjs -m` for the `--app gjs` bundle,
35
+ * the `--app node` bundle on node/bun/deno, and in BOTH cases the native
36
+ * typelib/library environment for any `@gjsify` native package the bundle
37
+ * reaches. The env is not optional on the gjs arm either — a watch loop that
38
+ * skipped it would run an app that `gjsify run` can start and this cannot.
39
+ *
40
+ * `subject` names what is being run in the node-gi diagnostic ("the storybook",
41
+ * "this project").
42
+ */
43
+ export async function spawnBundleSupervised(runtime, bundlePath, cwd, subject) {
44
+ if (runtime !== 'gjs') {
45
+ // Only a bundle that actually reaches gi:// needs the reverse bridge;
46
+ // the bundler keeps `@gjsify/node-gi` external, so its presence in the
47
+ // output is the question. A plain Node app must run without it.
48
+ const { bundleRequiresNodeGi, resolveNodeGiForBundle } = await import('./run-node.js');
49
+ // Same resolution base as `runRuntimeBundle`: the bundle's own dir first
50
+ // (that is where the runtime resolves the external specifier from), cwd
51
+ // as the fallback.
52
+ if (bundleRequiresNodeGi(bundlePath) && !resolveNodeGiForBundle(bundlePath, cwd)) {
53
+ throw new Error(`Cannot run ${subject} on ${runtime}: \`@gjsify/node-gi\` is not installed. ` +
54
+ `Add @gjsify/node-gi as a dependency to run ${subject} on ${runtime}.`);
55
+ }
56
+ }
57
+ const { computeNativeEnvForBundle } = await import('./run-gjs.js');
58
+ const { env: nativeEnv } = computeNativeEnvForBundle(bundlePath, cwd);
59
+ const env = { ...process.env, ...nativeEnv };
60
+ if (runtime === 'gjs') {
61
+ return spawnSupervised('gjs', ['-m', bundlePath], env);
62
+ }
63
+ // `node` uses the resolved node binary (under a GJS-hosted CLI
64
+ // `process.execPath` is gjs); bun/deno launch via the shared spec (deno's
65
+ // `--node-modules-dir=manual`, etc.).
66
+ if (runtime === 'node') {
67
+ const { nodeBinary } = await import('./run-node.js');
68
+ return spawnSupervised(nodeBinary(), [bundlePath], env);
69
+ }
70
+ const [cmd, launchArgs] = RUNTIMES[runtime].launch(bundlePath);
71
+ return spawnSupervised(cmd, launchArgs, env);
72
+ }
73
+ /**
74
+ * Whether a watcher event is the loop's OWN output rather than a source edit.
75
+ *
76
+ * Without this the loop feeds itself whenever the bundle lands inside the
77
+ * watched tree, which is the DEFAULT arrangement rather than an exotic one: the
78
+ * watch dir defaults to the entry point's directory, so `gjsify dev index.ts` on
79
+ * a project building to `dist/` watches the project root and every build it runs
80
+ * is a change under it. Measured before this filter: one edit to `index.ts`
81
+ * started a rebuild every ~5 s that never stopped.
82
+ *
83
+ * Two shapes, because a build writes more than the file it is named after:
84
+ *
85
+ * - the bundle itself and anything sharing its stem (`out.mjs.map`), which the
86
+ * bundler writes beside it in the same pass;
87
+ * - the whole output DIRECTORY — but only when it is not the watched directory
88
+ * itself. `--outfile dist/out.mjs` under a watch dir of `.` puts chunks and
89
+ * assets there too; an `--outfile` written straight into the watched dir has
90
+ * no directory to exclude that would not also exclude the sources.
91
+ *
92
+ * `filename` is what `fs.watch` reports, relative to `dir` — `null` on the
93
+ * platforms that cannot name the changed path, where the honest answer is "no
94
+ * idea", i.e. rebuild.
95
+ *
96
+ * BOTH operands are resolved before they are compared. Resolving only the
97
+ * changed path is right on POSIX for as long as every caller happens to pass an
98
+ * already-absolute `output`, and silently wrong the moment one does not: on
99
+ * win32 `resolve()` also rewrites the separators and picks up the drive, so the
100
+ * two sides stop being the same spelling of the same file and every self-write
101
+ * reads as a source edit — which is the feedback loop this filter exists to
102
+ * stop. Caught by the win32 leg; on Linux all five cases passed.
103
+ */
104
+ export function isSelfWrite(dir, output, filename) {
105
+ if (output === undefined || filename === null)
106
+ return false;
107
+ const changed = resolve(dir, filename);
108
+ const out = resolve(dir, output);
109
+ if (changed === out || changed.startsWith(`${out}.`))
110
+ return true;
111
+ const outDir = dirname(out);
112
+ if (outDir === resolve(dir))
113
+ return false;
114
+ return changed === outDir || changed.startsWith(`${outDir}${sep}`);
115
+ }
116
+ /**
117
+ * Run the loop: build + launch once, then rebuild + relaunch on every change
118
+ * under `dir`. Returns once the watcher is armed; the process is then held open
119
+ * — by the watcher handle on Node, by the GLib main loop on GJS — so there is
120
+ * nothing left to await. That the RESOLUTION is not completion is the one thing
121
+ * a caller has to know, which is why it is also declared through
122
+ * `markDaemonCommand()` for the callers that cannot see this signature.
123
+ *
124
+ * A build failure is reported and the loop KEEPS WATCHING: the next save is the
125
+ * fix, and a dev loop that dies on a syntax error is a dev loop nobody uses.
126
+ */
127
+ export async function runWatchLoop(opts) {
128
+ const { dir, label, prepare, build, spawnChild, output, debounceMs = 200 } = opts;
129
+ // Say so BEFORE the first build: `gjsify run`'s in-process dispatch acts on
130
+ // this the moment the handler resolves, and a first build that throws still
131
+ // leaves the loop watching.
132
+ markDaemonCommand();
133
+ let child = null;
134
+ let rebuilding = false;
135
+ let pending = false;
136
+ const rebuild = async () => {
137
+ if (rebuilding) {
138
+ pending = true;
139
+ return;
140
+ }
141
+ rebuilding = true;
142
+ try {
143
+ if (prepare)
144
+ await prepare();
145
+ if (child) {
146
+ child.kill();
147
+ child = null;
148
+ }
149
+ await build();
150
+ if (spawnChild) {
151
+ child = await spawnChild();
152
+ }
153
+ }
154
+ catch (err) {
155
+ console.error(`[${label}] rebuild failed: ${err.message}`);
156
+ }
157
+ finally {
158
+ rebuilding = false;
159
+ if (pending) {
160
+ pending = false;
161
+ void rebuild();
162
+ }
163
+ }
164
+ };
165
+ await rebuild();
166
+ let debounce = null;
167
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
168
+ if (isSelfWrite(dir, output, typeof filename === 'string' ? filename : null))
169
+ return;
170
+ if (debounce)
171
+ clearTimeout(debounce);
172
+ debounce = setTimeout(() => void rebuild(), debounceMs);
173
+ });
174
+ // Under GJS nothing else keeps us here. An `fs.watch` handle is a Gio file
175
+ // monitor, not a Node handle count, and the supervised spawn's own
176
+ // `ensureMainLoop()` declines to arm anything: it is reached from a
177
+ // continuation that GJS resumed from inside the main-context spin it runs
178
+ // while our entry module's top-level await is pending, where
179
+ // `GLib.main_depth()` is 1. So the loop used to print "watching …" and exit
180
+ // 0 on the very host `gjsify dev` exists for, leaving the app orphaned.
181
+ // `holdMainLoop()` is the opt-in that arms it anyway; `shutdown()` below is
182
+ // what takes it back down.
183
+ holdMainLoop();
184
+ // Ctrl+C is how this command ENDS, so the supervised child has to be taken
185
+ // down with it: the loop spawns the app in the CLI's own process group, and
186
+ // a SIGINT delivered to the CLI alone leaves a live GTK window behind with
187
+ // nothing watching it. Registering a handler also removes Node's default
188
+ // terminate, so the exit has to be explicit — and under GJS it is required
189
+ // anyway, since the loop held above is what is keeping us alive. Delivery
190
+ // there is `@gjsify/process` arming a `GLibUnix.signal_add()` source on the
191
+ // first listener, which dispatches on that same held loop; before it existed
192
+ // these two lines registered listeners nothing ever emitted.
193
+ let stopping = false;
194
+ const shutdown = () => {
195
+ if (stopping)
196
+ return;
197
+ stopping = true;
198
+ if (debounce)
199
+ clearTimeout(debounce);
200
+ child?.kill();
201
+ watcher.close();
202
+ process.exit(0);
203
+ };
204
+ process.on('SIGINT', shutdown);
205
+ process.on('SIGTERM', shutdown);
206
+ console.log(`[${label}] watching ${opts.dirLabel ?? dir} — press Ctrl+C to stop`);
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -142,20 +142,21 @@
142
142
  "cli"
143
143
  ],
144
144
  "dependencies": {
145
- "@gjsify/buffer": "^0.41.0",
146
- "@gjsify/create-app": "^0.41.0",
147
- "@gjsify/node-globals": "^0.41.0",
148
- "@gjsify/node-polyfills": "^0.41.0",
149
- "@gjsify/npm-registry": "^0.41.0",
150
- "@gjsify/resolve-npm": "^0.41.0",
151
- "@gjsify/rolldown-plugin-gjsify": "^0.41.0",
152
- "@gjsify/vite-plugin-blueprint": "^0.41.0",
153
- "@gjsify/rolldown-plugin-pnp": "^0.41.0",
154
- "@gjsify/semver": "^0.41.0",
155
- "@gjsify/tar": "^0.41.0",
156
- "@gjsify/tsc": "^0.41.0",
157
- "@gjsify/web-polyfills": "^0.41.0",
158
- "@gjsify/workspace": "^0.41.0",
145
+ "@gjsify/buffer": "^0.42.0",
146
+ "@gjsify/create-app": "^0.42.0",
147
+ "@gjsify/node-globals": "^0.42.0",
148
+ "@gjsify/node-polyfills": "^0.42.0",
149
+ "@gjsify/npm-registry": "^0.42.0",
150
+ "@gjsify/resolve-npm": "^0.42.0",
151
+ "@gjsify/rolldown-plugin-gjsify": "^0.42.0",
152
+ "@gjsify/vite-plugin-blueprint": "^0.42.0",
153
+ "@gjsify/rolldown-plugin-pnp": "^0.42.0",
154
+ "@gjsify/semver": "^0.42.0",
155
+ "@gjsify/tar": "^0.42.0",
156
+ "@gjsify/tsc": "^0.42.0",
157
+ "@gjsify/utils": "^0.42.0",
158
+ "@gjsify/web-polyfills": "^0.42.0",
159
+ "@gjsify/workspace": "^0.42.0",
159
160
  "acorn": "^8.17.0",
160
161
  "cosmiconfig": "^9.0.2",
161
162
  "get-tsconfig": "^4.14.0",
@@ -164,13 +165,13 @@
164
165
  "yargs": "^18.0.0"
165
166
  },
166
167
  "devDependencies": {
167
- "@gjsify/unit": "^0.41.0",
168
+ "@gjsify/unit": "^0.42.0",
168
169
  "@types/yargs": "^17.0.35",
169
170
  "typescript": "^6.0.3"
170
171
  },
171
172
  "peerDependencies": {
172
- "@gjsify/oxfmt-native": "^0.41.0",
173
- "@gjsify/rolldown-native": "^0.41.0"
173
+ "@gjsify/oxfmt-native": "^0.42.0",
174
+ "@gjsify/rolldown-native": "^0.42.0"
174
175
  },
175
176
  "peerDependenciesMeta": {
176
177
  "@gjsify/oxfmt-native": {
@@ -1,10 +0,0 @@
1
- [Desktop Entry]
2
- Name={{NAME}}
3
- Comment={{SUMMARY}}
4
- Exec={{COMMAND}}
5
- Icon={{APP_ID}}
6
- Terminal=false
7
- Type=Application
8
- Categories={{CATEGORIES_LINE}}
9
- {{KEYWORDS_LINE}}{{MIMETYPES_LINE}}StartupNotify=true
10
- StartupWMClass={{APP_ID}}