@gtkx/cli 1.0.0-rc.3 → 1.0.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 (170) hide show
  1. package/README.md +8 -9
  2. package/dist/builder.d.ts +1 -1
  3. package/dist/builder.d.ts.map +1 -1
  4. package/dist/builder.js +2 -1
  5. package/dist/builder.js.map +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codegen/freshness.d.ts.map +1 -1
  9. package/dist/codegen/freshness.js +3 -6
  10. package/dist/codegen/freshness.js.map +1 -1
  11. package/dist/codegen/run-codegen.d.ts +4 -4
  12. package/dist/codegen/run-codegen.d.ts.map +1 -1
  13. package/dist/codegen/run-codegen.js +15 -14
  14. package/dist/codegen/run-codegen.js.map +1 -1
  15. package/dist/codegen/store-resolver.d.ts +1 -3
  16. package/dist/codegen/store-resolver.d.ts.map +1 -1
  17. package/dist/codegen/store-resolver.js +13 -32
  18. package/dist/codegen/store-resolver.js.map +1 -1
  19. package/dist/commands/build.js +1 -1
  20. package/dist/commands/build.js.map +1 -1
  21. package/dist/commands/codegen.js +1 -1
  22. package/dist/commands/codegen.js.map +1 -1
  23. package/dist/commands/dev.d.ts.map +1 -1
  24. package/dist/commands/dev.js +4 -2
  25. package/dist/commands/dev.js.map +1 -1
  26. package/dist/commands/docs.js +13 -13
  27. package/dist/commands/docs.js.map +1 -1
  28. package/dist/dev/entry-env.d.ts +3 -0
  29. package/dist/dev/entry-env.d.ts.map +1 -0
  30. package/dist/dev/entry-env.js +3 -0
  31. package/dist/dev/entry-env.js.map +1 -0
  32. package/dist/dev/runner-deps.d.ts.map +1 -1
  33. package/dist/dev/runner-deps.js +1 -0
  34. package/dist/dev/runner-deps.js.map +1 -1
  35. package/dist/dev/runner-main.d.ts.map +1 -1
  36. package/dist/dev/runner-main.js +3 -3
  37. package/dist/dev/runner-main.js.map +1 -1
  38. package/dist/dev/runner.d.ts +1 -0
  39. package/dist/dev/runner.d.ts.map +1 -1
  40. package/dist/dev/runner.js +22 -12
  41. package/dist/dev/runner.js.map +1 -1
  42. package/dist/dev/supervisor.d.ts +10 -3
  43. package/dist/dev/supervisor.d.ts.map +1 -1
  44. package/dist/dev/supervisor.js +37 -18
  45. package/dist/dev/supervisor.js.map +1 -1
  46. package/dist/internal/application-args.d.ts +6 -0
  47. package/dist/internal/application-args.d.ts.map +1 -0
  48. package/dist/internal/application-args.js +10 -0
  49. package/dist/internal/application-args.js.map +1 -0
  50. package/dist/internal/errors.d.ts +1 -2
  51. package/dist/internal/errors.d.ts.map +1 -1
  52. package/dist/internal/errors.js +1 -1
  53. package/dist/internal/errors.js.map +1 -1
  54. package/dist/internal/run-cli-tool.d.ts +10 -0
  55. package/dist/internal/run-cli-tool.d.ts.map +1 -0
  56. package/dist/internal/run-cli-tool.js +15 -0
  57. package/dist/internal/run-cli-tool.js.map +1 -0
  58. package/dist/internal.d.ts +3 -0
  59. package/dist/internal.d.ts.map +1 -0
  60. package/dist/internal.js +3 -0
  61. package/dist/internal.js.map +1 -0
  62. package/dist/mcp/client.d.ts +1 -1
  63. package/dist/mcp/client.d.ts.map +1 -1
  64. package/dist/mcp/client.js.map +1 -1
  65. package/dist/mcp/handlers.d.ts +1 -1
  66. package/dist/mcp/handlers.d.ts.map +1 -1
  67. package/dist/mcp/handlers.js +79 -42
  68. package/dist/mcp/handlers.js.map +1 -1
  69. package/dist/mcp/index.d.ts.map +1 -1
  70. package/dist/mcp/index.js +1 -0
  71. package/dist/mcp/index.js.map +1 -1
  72. package/dist/mcp/serialize-widget.d.ts +2 -2
  73. package/dist/mcp/serialize-widget.d.ts.map +1 -1
  74. package/dist/mcp/serialize-widget.js +47 -17
  75. package/dist/mcp/serialize-widget.js.map +1 -1
  76. package/dist/mcp/widget-properties.d.ts +6 -0
  77. package/dist/mcp/widget-properties.d.ts.map +1 -0
  78. package/dist/mcp/widget-properties.js +102 -0
  79. package/dist/mcp/widget-properties.js.map +1 -0
  80. package/dist/refresh-runtime.d.ts +0 -10
  81. package/dist/refresh-runtime.d.ts.map +1 -1
  82. package/dist/refresh-runtime.js +0 -17
  83. package/dist/refresh-runtime.js.map +1 -1
  84. package/dist/settings/compile.d.ts.map +1 -1
  85. package/dist/settings/compile.js +8 -12
  86. package/dist/settings/compile.js.map +1 -1
  87. package/dist/settings/schema.d.ts +1 -1
  88. package/dist/settings/schema.d.ts.map +1 -1
  89. package/dist/settings/schema.js +1 -1
  90. package/dist/settings/schema.js.map +1 -1
  91. package/dist/vite-plugins/asset-emitter.d.ts +9 -0
  92. package/dist/vite-plugins/asset-emitter.d.ts.map +1 -0
  93. package/dist/vite-plugins/asset-emitter.js +2 -0
  94. package/dist/vite-plugins/asset-emitter.js.map +1 -0
  95. package/dist/vite-plugins/asset-extensions.d.ts +1 -2
  96. package/dist/vite-plugins/asset-extensions.d.ts.map +1 -1
  97. package/dist/vite-plugins/asset-extensions.js +1 -1
  98. package/dist/vite-plugins/asset-extensions.js.map +1 -1
  99. package/dist/vite-plugins/built-url.d.ts.map +1 -1
  100. package/dist/vite-plugins/built-url.js +14 -10
  101. package/dist/vite-plugins/built-url.js.map +1 -1
  102. package/dist/vite-plugins/ensure-store.d.ts +4 -0
  103. package/dist/vite-plugins/ensure-store.d.ts.map +1 -0
  104. package/dist/vite-plugins/ensure-store.js +10 -0
  105. package/dist/vite-plugins/ensure-store.js.map +1 -0
  106. package/dist/vite-plugins/icons.d.ts.map +1 -1
  107. package/dist/vite-plugins/icons.js.map +1 -1
  108. package/dist/vite-plugins/react-compiler.d.ts +1 -1
  109. package/dist/vite-plugins/react-compiler.d.ts.map +1 -1
  110. package/dist/vite-plugins/react-compiler.js.map +1 -1
  111. package/dist/vite-plugins/resources.d.ts +1 -1
  112. package/dist/vite-plugins/resources.d.ts.map +1 -1
  113. package/dist/vite-plugins/resources.js +6 -15
  114. package/dist/vite-plugins/resources.js.map +1 -1
  115. package/dist/vite-plugins/settings.js.map +1 -1
  116. package/dist/vite-plugins/undeclared-library.d.ts +1 -1
  117. package/dist/vite-plugins/undeclared-library.d.ts.map +1 -1
  118. package/dist/vite-plugins/undeclared-library.js.map +1 -1
  119. package/dist/vite-plugins/worker.d.ts +4 -0
  120. package/dist/vite-plugins/worker.d.ts.map +1 -0
  121. package/dist/vite-plugins/worker.js +136 -0
  122. package/dist/vite-plugins/worker.js.map +1 -0
  123. package/dist/vitest-plugin.d.ts +4 -3
  124. package/dist/vitest-plugin.d.ts.map +1 -1
  125. package/dist/vitest-plugin.js +11 -4
  126. package/dist/vitest-plugin.js.map +1 -1
  127. package/env.d.ts +25 -0
  128. package/package.json +17 -13
  129. package/src/builder.ts +3 -2
  130. package/src/cli.ts +4 -2
  131. package/src/codegen/freshness.ts +3 -8
  132. package/src/codegen/run-codegen.ts +19 -19
  133. package/src/codegen/store-resolver.ts +14 -40
  134. package/src/commands/build.ts +1 -1
  135. package/src/commands/codegen.ts +1 -1
  136. package/src/commands/dev.ts +4 -2
  137. package/src/commands/docs.ts +15 -15
  138. package/src/dev/entry-env.ts +3 -0
  139. package/src/dev/runner-deps.ts +1 -0
  140. package/src/dev/runner-main.ts +3 -4
  141. package/src/dev/runner.ts +44 -13
  142. package/src/dev/supervisor.ts +70 -27
  143. package/src/internal/application-args.ts +13 -0
  144. package/src/internal/errors.ts +1 -1
  145. package/src/internal/run-cli-tool.ts +22 -0
  146. package/src/internal.ts +2 -0
  147. package/src/mcp/client.ts +1 -1
  148. package/src/mcp/handlers.ts +124 -57
  149. package/src/mcp/index.ts +1 -0
  150. package/src/mcp/serialize-widget.ts +71 -21
  151. package/src/mcp/widget-properties.ts +153 -0
  152. package/src/refresh-runtime.ts +0 -17
  153. package/src/settings/compile.ts +8 -11
  154. package/src/settings/schema.ts +2 -2
  155. package/src/vite-plugins/asset-emitter.ts +5 -0
  156. package/src/vite-plugins/asset-extensions.ts +1 -1
  157. package/src/vite-plugins/built-url.ts +19 -13
  158. package/src/vite-plugins/ensure-store.ts +14 -0
  159. package/src/vite-plugins/icons.ts +2 -5
  160. package/src/vite-plugins/react-compiler.ts +3 -2
  161. package/src/vite-plugins/resources.ts +10 -19
  162. package/src/vite-plugins/settings.ts +2 -2
  163. package/src/vite-plugins/undeclared-library.ts +1 -1
  164. package/src/vite-plugins/worker.ts +235 -0
  165. package/src/vitest-plugin.ts +12 -4
  166. package/dist/internal/resolve-cli-tool.d.ts +0 -3
  167. package/dist/internal/resolve-cli-tool.d.ts.map +0 -1
  168. package/dist/internal/resolve-cli-tool.js +0 -27
  169. package/dist/internal/resolve-cli-tool.js.map +0 -1
  170. package/src/internal/resolve-cli-tool.ts +0 -37
package/src/dev/runner.ts CHANGED
@@ -14,6 +14,7 @@ type DevRunnerDeps = {
14
14
  ): Promise<unknown>;
15
15
  stopMcpClient(): void;
16
16
  watchApplicationShutdown(onShutdown: () => void): void;
17
+ isApplicationRegistered(): boolean;
17
18
  installShutdownHandlers(onSignal: () => void | Promise<void>): void;
18
19
  quitDefaultApplication(): void;
19
20
  performRefresh: () => void;
@@ -126,7 +127,11 @@ const onShutdownSignal =
126
127
  });
127
128
  };
128
129
 
129
- const closeAndExit = async (deps: DevRunnerDeps, controller: ShutdownController): Promise<never> => {
130
+ const closeAndExit = async (
131
+ deps: DevRunnerDeps,
132
+ controller: ShutdownController,
133
+ code = 0,
134
+ ): Promise<never> => {
130
135
  try {
131
136
  await controller.shutdown((): void => undefined);
132
137
  } catch (error_) {
@@ -135,7 +140,7 @@ const closeAndExit = async (deps: DevRunnerDeps, controller: ShutdownController)
135
140
  return deps.exit(1);
136
141
  }
137
142
 
138
- return deps.exit(0);
143
+ return deps.exit(code);
139
144
  };
140
145
 
141
146
  const onApplicationShutdown =
@@ -155,6 +160,42 @@ const onApplicationShutdown =
155
160
  void closeAndExit(deps, controller);
156
161
  };
157
162
 
163
+ const refusedExitCode = (): number => (process.exitCode === undefined ? 1 : Number(process.exitCode));
164
+
165
+ const connectApplication = async (
166
+ deps: DevRunnerDeps,
167
+ server: DevServer,
168
+ liveApplicationId: string,
169
+ tracked: { refreshTracker: RefreshTracker; controller: ShutdownController },
170
+ ): Promise<void> => {
171
+ deps.watchApplicationShutdown(onApplicationShutdown(deps, tracked.refreshTracker, tracked.controller));
172
+ const applicationId = (await deps.getConfiguredApplicationId(process.cwd())) ?? liveApplicationId;
173
+ deps.log(`Connected application ID: ${applicationId}`);
174
+ await deps.startMcpClient(applicationId, (id) => server.ssrLoadModule(id));
175
+ };
176
+
177
+ const attachApplication = async (
178
+ deps: DevRunnerDeps,
179
+ server: DevServer,
180
+ tracked: { refreshTracker: RefreshTracker; controller: ShutdownController },
181
+ ): Promise<void> => {
182
+ const liveApplicationId = await deps.waitForApplicationId(APPLICATION_MOUNT_TIMEOUT_MS);
183
+
184
+ if (liveApplicationId === null) {
185
+ deps.log("Entry did not mount an application - MCP client not started.");
186
+
187
+ return;
188
+ }
189
+
190
+ if (!deps.isApplicationRegistered()) {
191
+ deps.log("Application refused its command line - stopping dev runner...");
192
+
193
+ return closeAndExit(deps, tracked.controller, refusedExitCode());
194
+ }
195
+
196
+ await connectApplication(deps, server, liveApplicationId, tracked);
197
+ };
198
+
158
199
  const createDevRunner = (deps: DevRunnerDeps): DevRunner => ({
159
200
  async run(entryPath: string): Promise<void> {
160
201
  const root = process.cwd();
@@ -166,17 +207,7 @@ const createDevRunner = (deps: DevRunnerDeps): DevRunner => ({
166
207
  server.watcher.on("change", onFileChange(server, refreshTrackingDeps, controller));
167
208
  deps.log(`Loading entry: ${entryPath}`);
168
209
  await server.ssrLoadModule(entryPath);
169
- const liveApplicationId = await deps.waitForApplicationId(APPLICATION_MOUNT_TIMEOUT_MS);
170
-
171
- if (liveApplicationId) {
172
- deps.watchApplicationShutdown(onApplicationShutdown(deps, refreshTracker, controller));
173
- const applicationId = (await deps.getConfiguredApplicationId(root)) ?? liveApplicationId;
174
- deps.log(`Connected application ID: ${applicationId}`);
175
- await deps.startMcpClient(applicationId, (id) => server.ssrLoadModule(id));
176
- } else {
177
- deps.log("Entry did not mount an application - MCP client not started.");
178
- }
179
-
210
+ await attachApplication(deps, server, { refreshTracker, controller });
180
211
  deps.log("HMR enabled - watching for changes...");
181
212
  },
182
213
  });
@@ -3,6 +3,7 @@ import { fork as nodeFork } from "node:child_process";
3
3
  import { type FSWatcher, watch as watchFs } from "node:fs";
4
4
  import { basename, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { DEV_ENTRY_ENV } from "./entry-env.js";
6
7
 
7
8
  type SupervisedChild = {
8
9
  killed: boolean;
@@ -13,7 +14,7 @@ type SupervisedChild = {
13
14
  once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
14
15
  };
15
16
 
16
- type ForkRunner = (modulePath: string, args: string[], cwd: string) => SupervisedChild;
17
+ type ForkRunner = (modulePath: string, args: string[], env: NodeJS.ProcessEnv, cwd: string) => SupervisedChild;
17
18
 
18
19
  type DevWatch = {
19
20
  paths: string[];
@@ -24,24 +25,55 @@ type SupervisorState = {
24
25
  runnerPath: string;
25
26
  entryPath: string;
26
27
  cwd: string;
28
+ args: string[];
27
29
  watch: DevWatch | undefined;
28
30
  watchers: FSWatcher[];
29
31
  fork: ForkRunner;
30
32
  child: SupervisedChild | null;
31
- shuttingDown: boolean;
32
- restarting: boolean;
33
+ isShuttingDown: boolean;
34
+ isRestarting: boolean;
33
35
  capturedChildExit: number | undefined;
34
36
  };
35
37
 
38
+ type DevSupervisorOptions = {
39
+ entryPath: string;
40
+ cwd: string;
41
+ args?: string[] | undefined;
42
+ watch?: DevWatch | undefined;
43
+ fork?: ForkRunner | undefined;
44
+ };
45
+
36
46
  type DebounceTimer = { handle: NodeJS.Timeout | null };
37
47
 
38
48
  const DEV_RUNNER_URL = new URL("../../bin/gtkx-dev-runner.js", import.meta.url);
39
49
  const FORCE_KILL_TIMEOUT_MS = 5000;
40
50
  const CONFIG_DEBOUNCE_MS = 150;
41
51
  const RESTART_EXIT_CODE = 75;
52
+ const CONDITION_FLAG = /^(?:--conditions|-C)(?:=.*)?$/;
53
+ const SPLIT_CONDITION_FLAG = /^(?:--conditions|-C)$/;
54
+
55
+ const withoutConditions = (argv: string[]): string[] =>
56
+ argv.filter(
57
+ (argument, index) => !CONDITION_FLAG.test(argument) && !SPLIT_CONDITION_FLAG.test(argv[index - 1] ?? ""),
58
+ );
59
+
60
+ const getNodeOptions = (env: NodeJS.ProcessEnv): string | undefined => {
61
+ const options = env.NODE_OPTIONS;
62
+
63
+ return options === undefined ? undefined : withoutConditions(options.split(/\s+/)).join(" ");
64
+ };
42
65
 
43
- const defaultForkRunner: ForkRunner = (modulePath, args, cwd) =>
44
- nodeFork(modulePath, [...args], { cwd, stdio: "inherit", detached: true });
66
+ const defaultForkRunner: ForkRunner = (modulePath, args, env, cwd) => {
67
+ const nodeOptions = getNodeOptions(env);
68
+
69
+ return nodeFork(modulePath, args, {
70
+ cwd,
71
+ env: { ...env, ...(nodeOptions !== undefined && { NODE_OPTIONS: nodeOptions }) },
72
+ stdio: "inherit",
73
+ detached: true,
74
+ execArgv: withoutConditions(process.execArgv),
75
+ });
76
+ };
45
77
 
46
78
  const forwardSignal = (child: SupervisedChild, signal: NodeJS.Signals): void => {
47
79
  if (!child.killed) {
@@ -72,11 +104,11 @@ const captureShutdownExit = (state: SupervisorState, code: number | null, signal
72
104
  const handleChildExit = (state: SupervisorState, code: number | null, signal: NodeJS.Signals | null): void => {
73
105
  state.child = null;
74
106
 
75
- if (state.restarting) {
107
+ if (state.isRestarting) {
76
108
  return;
77
109
  }
78
110
 
79
- if (state.shuttingDown) {
111
+ if (state.isShuttingDown) {
80
112
  captureShutdownExit(state, code, signal);
81
113
 
82
114
  return;
@@ -93,7 +125,13 @@ const handleChildExit = (state: SupervisorState, code: number | null, signal: No
93
125
  };
94
126
 
95
127
  const launch = (state: SupervisorState): void => {
96
- const child = state.fork(state.runnerPath, [state.entryPath], state.cwd);
128
+ const child = state.fork(
129
+ state.runnerPath,
130
+ state.args,
131
+ { ...process.env, [DEV_ENTRY_ENV]: state.entryPath },
132
+ state.cwd,
133
+ );
134
+
97
135
  state.child = child;
98
136
 
99
137
  child.on("exit", (code, signal) => {
@@ -101,12 +139,12 @@ const launch = (state: SupervisorState): void => {
101
139
  });
102
140
  };
103
141
 
104
- const isRestartBlocked = (state: SupervisorState): boolean => state.restarting || state.shuttingDown;
142
+ const isRestartBlocked = (state: SupervisorState): boolean => state.isRestarting || state.isShuttingDown;
105
143
 
106
144
  const relaunchAfterExit = (state: SupervisorState): void => {
107
- state.restarting = false;
145
+ state.isRestarting = false;
108
146
 
109
- if (state.shuttingDown) {
147
+ if (state.isShuttingDown) {
110
148
  return;
111
149
  }
112
150
 
@@ -121,20 +159,20 @@ const restart = async (state: SupervisorState): Promise<void> => {
121
159
  return;
122
160
  }
123
161
 
124
- state.restarting = true;
162
+ state.isRestarting = true;
125
163
  info("gtkx.config.ts changed; regenerating bindings...");
126
164
 
127
165
  try {
128
166
  await watch.regenerate();
129
167
  } catch (error_) {
130
168
  error("Codegen failed; keeping the current dev runner. Fix the error and save again.", error_);
131
- state.restarting = false;
169
+ state.isRestarting = false;
132
170
 
133
171
  return;
134
172
  }
135
173
 
136
- if (state.shuttingDown) {
137
- state.restarting = false;
174
+ if (state.isShuttingDown) {
175
+ state.isRestarting = false;
138
176
 
139
177
  return;
140
178
  }
@@ -142,7 +180,7 @@ const restart = async (state: SupervisorState): Promise<void> => {
142
180
  const current = state.child;
143
181
 
144
182
  if (current === null) {
145
- state.restarting = false;
183
+ state.isRestarting = false;
146
184
  launch(state);
147
185
 
148
186
  return;
@@ -164,7 +202,7 @@ const scheduleRestart = (state: SupervisorState, timer: DebounceTimer): void =>
164
202
  };
165
203
 
166
204
  const isWatchedChange = (state: SupervisorState, names: Set<string>, filename: string | Buffer | null): boolean => {
167
- if (filename === null || state.shuttingDown) {
205
+ if (filename === null || state.isShuttingDown) {
168
206
  return false;
169
207
  }
170
208
 
@@ -222,7 +260,7 @@ const closeWatchers = (state: SupervisorState): void => {
222
260
 
223
261
  const shutdownOnSignal = (state: SupervisorState, signal: NodeJS.Signals): Promise<void> =>
224
262
  new Promise<void>((resolve) => {
225
- state.shuttingDown = true;
263
+ state.isShuttingDown = true;
226
264
  closeWatchers(state);
227
265
 
228
266
  if (!state.child) {
@@ -247,22 +285,20 @@ const installShutdown = (state: SupervisorState): void => {
247
285
  });
248
286
  };
249
287
 
250
- const runDevSupervisor = async (
251
- entryPath: string,
252
- cwd: string,
253
- watch?: DevWatch,
254
- fork: ForkRunner = defaultForkRunner,
255
- ): Promise<never> => {
288
+ const runDevSupervisor = async (options: DevSupervisorOptions): Promise<never> => {
289
+ const { entryPath, cwd, args = [], watch, fork = defaultForkRunner } = options;
290
+
256
291
  const state: SupervisorState = {
257
292
  runnerPath: fileURLToPath(DEV_RUNNER_URL),
258
293
  entryPath,
259
294
  cwd,
295
+ args,
260
296
  watch,
261
297
  watchers: [],
262
298
  fork,
263
299
  child: null,
264
- shuttingDown: false,
265
- restarting: false,
300
+ isShuttingDown: false,
301
+ isRestarting: false,
266
302
  capturedChildExit: undefined,
267
303
  };
268
304
 
@@ -273,4 +309,11 @@ const runDevSupervisor = async (
273
309
  return new Promise<never>((): void => undefined);
274
310
  };
275
311
 
276
- export { RESTART_EXIT_CODE, defaultForkRunner, runDevSupervisor, type SupervisedChild, type ForkRunner, type DevWatch };
312
+ export {
313
+ RESTART_EXIT_CODE,
314
+ defaultForkRunner,
315
+ runDevSupervisor,
316
+ type DevWatch,
317
+ type ForkRunner,
318
+ type SupervisedChild,
319
+ };
@@ -0,0 +1,13 @@
1
+ const APPLICATION_ARGS_SEPARATOR = "--";
2
+
3
+ const splitApplicationArgs = (argv: string[]): { cliArgs: string[]; applicationArgs: string[] } => {
4
+ const separator = argv.indexOf(APPLICATION_ARGS_SEPARATOR);
5
+
6
+ if (separator === -1) {
7
+ return { cliArgs: argv, applicationArgs: [] };
8
+ }
9
+
10
+ return { cliArgs: argv.slice(0, separator), applicationArgs: argv.slice(separator + 1) };
11
+ };
12
+
13
+ export { splitApplicationArgs };
@@ -33,4 +33,4 @@ const withErrorBoundary = <T extends ArgsDef>(command: CommandDef<T>): CommandDe
33
33
  };
34
34
  };
35
35
 
36
- export { printError, withErrorBoundary };
36
+ export { withErrorBoundary };
@@ -0,0 +1,22 @@
1
+ import { formatChildProcessError, resolveExecutable } from "@gtkx/utils";
2
+ import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
3
+
4
+ type CliToolInvocation = {
5
+ tool: string;
6
+ args: string[];
7
+ target?: string;
8
+ options?: ExecFileSyncOptions;
9
+ };
10
+
11
+ const runCliTool = ({ tool, args, target, options }: CliToolInvocation): void => {
12
+ try {
13
+ execFileSync(resolveExecutable(tool), args, options);
14
+ } catch (error) {
15
+ const details = formatChildProcessError(error);
16
+ const suffix = details ? `:\n${details}` : "";
17
+ const subject = target === undefined ? "" : ` for ${target}`;
18
+ throw new Error(`${tool} failed${subject}${suffix}`, { cause: error });
19
+ }
20
+ };
21
+
22
+ export { runCliTool };
@@ -0,0 +1,2 @@
1
+ export { dispatch } from "./mcp/handlers.js";
2
+ export { WidgetRegistry } from "./mcp/widget-registry.js";
package/src/mcp/client.ts CHANGED
@@ -232,4 +232,4 @@ class McpClient {
232
232
  }
233
233
  }
234
234
 
235
- export { McpClient, type McpClientOptions };
235
+ export { McpClient };
@@ -8,11 +8,10 @@ import {
8
8
  ServerRequestParamsSchemas,
9
9
  widgetNotFoundError,
10
10
  } from "@gtkx/mcp/internal";
11
- import { mkdirSync, writeFileSync } from "node:fs";
12
- import { dirname } from "node:path";
13
11
  import type { WidgetRegistry } from "./widget-registry.js";
14
12
  import { serializeWidget } from "./serialize-widget.js";
15
13
  import { loadTestingModule, type TestingModule } from "./testing-loader.js";
14
+ import { readWidgetProperties } from "./widget-properties.js";
16
15
 
17
16
  type HandlerContext = {
18
17
  app: Gtk.Application;
@@ -21,6 +20,11 @@ type HandlerContext = {
21
20
 
22
21
  type ValidatedHandler = (ctx: HandlerContext, params: unknown) => Promise<unknown>;
23
22
  type WidgetTarget = { testing: TestingModule; widget: Gtk.Widget };
23
+ type WidgetParams = { widgetId?: string | undefined };
24
+ type TargetedHandler<Params> = (ctx: HandlerContext, target: WidgetTarget, params: Params) => Promise<unknown>;
25
+ type QueryParams = ServerRequestParams<"widget.query">;
26
+ type QueryBy = QueryParams["by"];
27
+ type QueryRunner = (testing: TestingModule, app: Gtk.Application, params: QueryParams) => Promise<Gtk.Widget[]>;
24
28
 
25
29
  const HANDLERS: Record<ServerInitiatedMethod, ValidatedHandler> = {
26
30
  "app.getWindows": validated(ServerRequestParamsSchemas["app.getWindows"], ({ registry }) =>
@@ -37,26 +41,28 @@ const HANDLERS: Record<ServerInitiatedMethod, ValidatedHandler> = {
37
41
  return {
38
42
  tree: testing.prettyWidget(container, {
39
43
  getId: (w) => registry.getOrCreateId(w),
40
- highlight: false,
44
+ shouldHighlight: false,
41
45
  ...((params.maxDepth !== undefined) && { maxDepth: params.maxDepth }),
42
46
  }),
43
47
  };
44
48
  }),
45
49
  "widget.query": validated(ServerRequestParamsSchemas["widget.query"], handleQuery),
46
- "widget.getProps": validated(ServerRequestParamsSchemas["widget.getProps"], async ({ registry }, params) => {
47
- const { testing, widget } = await widgetTarget(registry, params.widgetId);
48
-
49
- return serializeWidget(widget, (target) => registry.getOrCreateId(target), testing);
50
- }),
51
- "widget.click": validated(ServerRequestParamsSchemas["widget.click"], async ({ registry }, params) => {
52
- const { testing, widget } = await widgetTarget(registry, params.widgetId);
50
+ "widget.getProps": targeted(
51
+ ServerRequestParamsSchemas["widget.getProps"],
52
+ ({ registry }, { testing, widget }, params) =>
53
+ Promise.resolve({
54
+ ...(params.properties !== undefined && {
55
+ properties: readWidgetProperties(widget, params.properties, registry),
56
+ }),
57
+ ...serializeWidget(widget, (target) => registry.getOrCreateId(target), testing, params.maxDepth),
58
+ }),
59
+ ),
60
+ "widget.click": targeted(ServerRequestParamsSchemas["widget.click"], async (_ctx, { testing, widget }) => {
53
61
  await testing.userEvent.click(widget);
54
62
 
55
63
  return { success: true };
56
64
  }),
57
- "widget.type": validated(ServerRequestParamsSchemas["widget.type"], async ({ registry }, params) => {
58
- const { testing, widget } = await widgetTarget(registry, params.widgetId);
59
-
65
+ "widget.type": targeted(ServerRequestParamsSchemas["widget.type"], async (_ctx, { testing, widget }, params) => {
60
66
  if (params.clear) {
61
67
  await testing.userEvent.clear(widget);
62
68
  }
@@ -65,16 +71,43 @@ const HANDLERS: Record<ServerInitiatedMethod, ValidatedHandler> = {
65
71
 
66
72
  return { success: true };
67
73
  }),
68
- "widget.fireEvent": validated(ServerRequestParamsSchemas["widget.fireEvent"], async ({ registry }, params) => {
69
- const { testing, widget } = await widgetTarget(registry, params.widgetId);
70
- const signalArgs = (params.args ?? []).map((arg) => extractSignalArg(arg));
71
- await testing.fireEvent(widget, params.signal, ...signalArgs);
72
-
73
- return { success: true };
74
- }),
74
+ "widget.fireEvent": targeted(
75
+ ServerRequestParamsSchemas["widget.fireEvent"],
76
+ async (_ctx, { testing, widget }, params) => {
77
+ const signalArgs = (params.args ?? []).map((arg) => extractSignalArg(arg));
78
+ const isRealized = widget.getRealized();
79
+ const isMapped = widget.getMapped();
80
+ const isSensitive = widget.getSensitive();
81
+ await testing.fireEvent(widget, params.signal, ...signalArgs);
82
+
83
+ return {
84
+ signal: params.signal,
85
+ isRealized,
86
+ isMapped,
87
+ isSensitive,
88
+ note: emissionNote(isRealized, isMapped),
89
+ };
90
+ },
91
+ ),
75
92
  "widget.screenshot": validated(ServerRequestParamsSchemas["widget.screenshot"], handleScreenshot),
76
93
  };
77
94
 
95
+ const QUERY_RUNNERS: Record<QueryBy, QueryRunner> = {
96
+ role: runRoleQuery,
97
+ text: runTextQuery,
98
+ name: runNameQuery,
99
+ labelText: runLabelTextQuery,
100
+ };
101
+
102
+ const SEARCHED_BY: Record<QueryBy, string> = {
103
+ role: "the accessible role, narrowed by any options given",
104
+ text: "the text the widget renders",
105
+ name:
106
+ "the widget name (gtk_widget_get_name, which reports the GType name such as \"GtkButton\" when no name " +
107
+ "was set), the accessible label, and the text the widget renders",
108
+ labelText: "the accessible label, the labelled-by relation, and the label whose mnemonic targets the widget",
109
+ };
110
+
78
111
  function validated<Params>(
79
112
  schema: ParamsSchema<Params>,
80
113
  handler: (ctx: HandlerContext, params: Params) => Promise<unknown>,
@@ -90,6 +123,14 @@ function validated<Params>(
90
123
  };
91
124
  }
92
125
 
126
+ function targeted<Params extends WidgetParams>(
127
+ schema: ParamsSchema<Params>,
128
+ handler: TargetedHandler<Params>,
129
+ ): ValidatedHandler {
130
+ return validated(schema, async (ctx, params) =>
131
+ handler(ctx, await widgetTarget(ctx.registry, params.widgetId), params));
132
+ }
133
+
93
134
  const widgetTarget = async (registry: WidgetRegistry, widgetId: string | undefined): Promise<WidgetTarget> => ({
94
135
  testing: await loadTestingModule(),
95
136
  widget: requireWidget(registry, widgetId),
@@ -115,6 +156,21 @@ const extractSignalArg = (arg: unknown): unknown => {
115
156
  return isTypedArg ? (arg as { value: unknown }).value : arg;
116
157
  };
117
158
 
159
+ const emissionNote = (isRealized: boolean, isMapped: boolean): string => {
160
+ if (isRealized && isMapped) {
161
+ return (
162
+ "The signal was emitted. Default handlers that animate, such as a button activate, settle " +
163
+ "asynchronously, so read the widget again rather than assuming the effect has landed."
164
+ );
165
+ }
166
+
167
+ return (
168
+ "The signal was emitted on a widget that is not realized and mapped, so a default handler that " +
169
+ "requires a drawn widget does nothing. Present the containing window or open the containing " +
170
+ "popover first, then fire the signal again."
171
+ );
172
+ };
173
+
118
174
  const resolveRole = (value: string | number): Gtk.AccessibleRole | undefined => {
119
175
  if (typeof value === "number") {
120
176
  return value;
@@ -133,42 +189,56 @@ const matchesOrEmpty = async (find: () => Promise<Gtk.Widget[]>): Promise<Gtk.Wi
133
189
  }
134
190
  };
135
191
 
136
- async function handleQuery(
137
- { app, registry }: HandlerContext,
138
- params: ServerRequestParams<"widget.query">,
139
- ): Promise<unknown> {
140
- const testing = await loadTestingModule();
141
- let widgets: Gtk.Widget[] = [];
192
+ function runRoleQuery(testing: TestingModule, app: Gtk.Application, params: QueryParams): Promise<Gtk.Widget[]> {
193
+ const roleValue = resolveRole(params.value);
142
194
 
143
- switch (params.by) {
144
- case "role": {
145
- const roleValue = resolveRole(params.value);
195
+ if (roleValue === undefined) {
196
+ throw invalidRequestError(
197
+ `Unknown accessible role "${String(params.value)}"; use the lowercase role shown in the ` +
198
+ "widget tree, e.g. \"button\", \"list\", \"list_item\", or \"checkbox\".",
199
+ );
200
+ }
146
201
 
147
- if (roleValue === undefined) {
148
- throw invalidRequestError(
149
- `Unknown accessible role "${String(params.value)}"; use the lowercase role shown in the ` +
150
- "widget tree, e.g. \"button\", \"list\", \"list_item\", or \"checkbox\".",
151
- );
152
- }
202
+ return matchesOrEmpty(() => testing.findAllByRole(app, roleValue, params.options));
203
+ }
153
204
 
154
- widgets = await matchesOrEmpty(() => testing.findAllByRole(app, roleValue, params.options));
155
- break;
156
- }
157
- case "text": {
158
- widgets = await matchesOrEmpty(() => testing.findAllByText(app, String(params.value), params.options));
159
- break;
160
- }
161
- case "name": {
162
- widgets = await matchesOrEmpty(() => testing.findAllByName(app, String(params.value), params.options));
163
- break;
164
- }
165
- case "labelText": {
166
- widgets = await matchesOrEmpty(() => testing.findAllByLabelText(app, String(params.value), params.options));
167
- break;
168
- }
169
- }
205
+ function runTextQuery(testing: TestingModule, app: Gtk.Application, params: QueryParams): Promise<Gtk.Widget[]> {
206
+ return matchesOrEmpty(() => testing.findAllByText(app, String(params.value), params.options));
207
+ }
170
208
 
171
- return { widgets: widgets.map((w) => serializeWidget(w, (widget) => registry.getOrCreateId(widget), testing, 0)) };
209
+ function runLabelTextQuery(testing: TestingModule, app: Gtk.Application, params: QueryParams): Promise<Gtk.Widget[]> {
210
+ return matchesOrEmpty(() => testing.findAllByLabelText(app, String(params.value), params.options));
211
+ }
212
+
213
+ async function runNameQuery(
214
+ testing: TestingModule,
215
+ app: Gtk.Application,
216
+ params: QueryParams,
217
+ ): Promise<Gtk.Widget[]> {
218
+ const matches = await Promise.all([
219
+ matchesOrEmpty(() => testing.findAllByName(app, String(params.value), params.options)),
220
+ runLabelTextQuery(testing, app, params),
221
+ runTextQuery(testing, app, params),
222
+ ]);
223
+
224
+ return [...new Set(matches.flat())];
225
+ }
226
+
227
+ const emptyQueryHint = (params: QueryParams): string =>
228
+ `Nothing matched by:"${params.by}" value:"${String(params.value)}", which compared ${SEARCHED_BY[params.by]}. ` +
229
+ "Call gtkx_get_widget_tree to see what is mounted; by:\"name\" is the widest match, and by:\"role\" accepts " +
230
+ "options.name to match the accessible name of a known role.";
231
+
232
+ async function handleQuery({ app, registry }: HandlerContext, params: QueryParams): Promise<unknown> {
233
+ const testing = await loadTestingModule();
234
+ const widgets = await QUERY_RUNNERS[params.by](testing, app, params);
235
+ const resolveId = (widget: Gtk.Widget): string => registry.getOrCreateId(widget);
236
+
237
+ return {
238
+ widgets: widgets.map((widget) => serializeWidget(widget, resolveId, testing, 0)),
239
+ searched: SEARCHED_BY[params.by],
240
+ ...(widgets.length === 0 && { hint: emptyQueryHint(params) }),
241
+ };
172
242
  }
173
243
 
174
244
  const defaultScreenshotTarget = (registry: WidgetRegistry): Gtk.Widget => {
@@ -187,12 +257,9 @@ async function handleScreenshot(
187
257
  ): Promise<unknown> {
188
258
  const testing = await loadTestingModule();
189
259
  const target = params.windowId ? requireWidget(registry, params.windowId) : defaultScreenshotTarget(registry);
190
- const result = await testing.screenshot(target);
260
+ const result = await testing.screenshot(target, { path: params.path });
191
261
 
192
262
  if (params.path) {
193
- mkdirSync(dirname(params.path), { recursive: true });
194
- writeFileSync(params.path, Buffer.from(result.data, "base64"));
195
-
196
263
  return { data: result.data, mimeType: result.mimeType, savedPath: params.path };
197
264
  }
198
265
 
@@ -210,4 +277,4 @@ const dispatch = async (method: string, params: unknown, ctx: HandlerContext): P
210
277
  return HANDLERS[method](ctx, params);
211
278
  };
212
279
 
213
- export { dispatch, type HandlerContext };
280
+ export { dispatch };
package/src/mcp/index.ts CHANGED
@@ -7,6 +7,7 @@ type McpClientController = {
7
7
 
8
8
  const { start: startMcpClient, stop: stopMcpClient } = createMcpClientController();
9
9
 
10
+ /* eslint-disable-next-line unicorn/consistent-boolean-name -- the boolean reports whether connecting succeeded */
10
11
  async function connectQuietly(client: McpClient): Promise<boolean> {
11
12
  try {
12
13
  await client.connect();