@gtkx/cli 1.0.0-rc.4 → 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 (149) hide show
  1. package/README.md +5 -5
  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 +0 -2
  10. package/dist/codegen/freshness.js.map +1 -1
  11. package/dist/codegen/run-codegen.d.ts +1 -1
  12. package/dist/codegen/run-codegen.d.ts.map +1 -1
  13. package/dist/codegen/run-codegen.js +1 -0
  14. package/dist/codegen/run-codegen.js.map +1 -1
  15. package/dist/codegen/store-resolver.d.ts +1 -1
  16. package/dist/codegen/store-resolver.d.ts.map +1 -1
  17. package/dist/codegen/store-resolver.js.map +1 -1
  18. package/dist/commands/dev.d.ts.map +1 -1
  19. package/dist/commands/dev.js +3 -1
  20. package/dist/commands/dev.js.map +1 -1
  21. package/dist/dev/entry-env.d.ts +3 -0
  22. package/dist/dev/entry-env.d.ts.map +1 -0
  23. package/dist/dev/entry-env.js +3 -0
  24. package/dist/dev/entry-env.js.map +1 -0
  25. package/dist/dev/runner-deps.d.ts.map +1 -1
  26. package/dist/dev/runner-deps.js +1 -0
  27. package/dist/dev/runner-deps.js.map +1 -1
  28. package/dist/dev/runner-main.d.ts.map +1 -1
  29. package/dist/dev/runner-main.js +3 -3
  30. package/dist/dev/runner-main.js.map +1 -1
  31. package/dist/dev/runner.d.ts +1 -0
  32. package/dist/dev/runner.d.ts.map +1 -1
  33. package/dist/dev/runner.js +22 -12
  34. package/dist/dev/runner.js.map +1 -1
  35. package/dist/dev/supervisor.d.ts +10 -3
  36. package/dist/dev/supervisor.d.ts.map +1 -1
  37. package/dist/dev/supervisor.js +23 -4
  38. package/dist/dev/supervisor.js.map +1 -1
  39. package/dist/internal/application-args.d.ts +6 -0
  40. package/dist/internal/application-args.d.ts.map +1 -0
  41. package/dist/internal/application-args.js +10 -0
  42. package/dist/internal/application-args.js.map +1 -0
  43. package/dist/internal/errors.d.ts +1 -2
  44. package/dist/internal/errors.d.ts.map +1 -1
  45. package/dist/internal/errors.js +1 -1
  46. package/dist/internal/errors.js.map +1 -1
  47. package/dist/internal/run-cli-tool.d.ts +10 -0
  48. package/dist/internal/run-cli-tool.d.ts.map +1 -0
  49. package/dist/internal/run-cli-tool.js +15 -0
  50. package/dist/internal/run-cli-tool.js.map +1 -0
  51. package/dist/internal.d.ts +3 -0
  52. package/dist/internal.d.ts.map +1 -0
  53. package/dist/internal.js +3 -0
  54. package/dist/internal.js.map +1 -0
  55. package/dist/mcp/client.d.ts +1 -1
  56. package/dist/mcp/client.d.ts.map +1 -1
  57. package/dist/mcp/client.js.map +1 -1
  58. package/dist/mcp/handlers.d.ts +1 -1
  59. package/dist/mcp/handlers.d.ts.map +1 -1
  60. package/dist/mcp/handlers.js +78 -41
  61. package/dist/mcp/handlers.js.map +1 -1
  62. package/dist/mcp/index.d.ts.map +1 -1
  63. package/dist/mcp/index.js +1 -0
  64. package/dist/mcp/index.js.map +1 -1
  65. package/dist/mcp/serialize-widget.d.ts +2 -2
  66. package/dist/mcp/serialize-widget.d.ts.map +1 -1
  67. package/dist/mcp/serialize-widget.js +47 -17
  68. package/dist/mcp/serialize-widget.js.map +1 -1
  69. package/dist/mcp/widget-properties.d.ts +6 -0
  70. package/dist/mcp/widget-properties.d.ts.map +1 -0
  71. package/dist/mcp/widget-properties.js +102 -0
  72. package/dist/mcp/widget-properties.js.map +1 -0
  73. package/dist/settings/compile.d.ts.map +1 -1
  74. package/dist/settings/compile.js +8 -12
  75. package/dist/settings/compile.js.map +1 -1
  76. package/dist/vite-plugins/asset-emitter.d.ts +9 -0
  77. package/dist/vite-plugins/asset-emitter.d.ts.map +1 -0
  78. package/dist/vite-plugins/asset-emitter.js +2 -0
  79. package/dist/vite-plugins/asset-emitter.js.map +1 -0
  80. package/dist/vite-plugins/asset-extensions.d.ts +1 -2
  81. package/dist/vite-plugins/asset-extensions.d.ts.map +1 -1
  82. package/dist/vite-plugins/asset-extensions.js +1 -1
  83. package/dist/vite-plugins/asset-extensions.js.map +1 -1
  84. package/dist/vite-plugins/built-url.d.ts.map +1 -1
  85. package/dist/vite-plugins/built-url.js +14 -10
  86. package/dist/vite-plugins/built-url.js.map +1 -1
  87. package/dist/vite-plugins/ensure-store.d.ts +4 -0
  88. package/dist/vite-plugins/ensure-store.d.ts.map +1 -0
  89. package/dist/vite-plugins/ensure-store.js +10 -0
  90. package/dist/vite-plugins/ensure-store.js.map +1 -0
  91. package/dist/vite-plugins/icons.d.ts.map +1 -1
  92. package/dist/vite-plugins/icons.js.map +1 -1
  93. package/dist/vite-plugins/react-compiler.d.ts +1 -1
  94. package/dist/vite-plugins/react-compiler.d.ts.map +1 -1
  95. package/dist/vite-plugins/react-compiler.js.map +1 -1
  96. package/dist/vite-plugins/resources.d.ts +1 -1
  97. package/dist/vite-plugins/resources.d.ts.map +1 -1
  98. package/dist/vite-plugins/resources.js +6 -15
  99. package/dist/vite-plugins/resources.js.map +1 -1
  100. package/dist/vite-plugins/settings.js.map +1 -1
  101. package/dist/vite-plugins/undeclared-library.d.ts +1 -1
  102. package/dist/vite-plugins/undeclared-library.d.ts.map +1 -1
  103. package/dist/vite-plugins/undeclared-library.js.map +1 -1
  104. package/dist/vite-plugins/worker.d.ts +4 -0
  105. package/dist/vite-plugins/worker.d.ts.map +1 -0
  106. package/dist/vite-plugins/worker.js +136 -0
  107. package/dist/vite-plugins/worker.js.map +1 -0
  108. package/dist/vitest-plugin.d.ts +7 -0
  109. package/dist/vitest-plugin.d.ts.map +1 -1
  110. package/dist/vitest-plugin.js +14 -1
  111. package/dist/vitest-plugin.js.map +1 -1
  112. package/package.json +16 -12
  113. package/src/builder.ts +3 -2
  114. package/src/cli.ts +4 -2
  115. package/src/codegen/freshness.ts +0 -2
  116. package/src/codegen/run-codegen.ts +1 -1
  117. package/src/codegen/store-resolver.ts +1 -1
  118. package/src/commands/dev.ts +3 -1
  119. package/src/dev/entry-env.ts +3 -0
  120. package/src/dev/runner-deps.ts +1 -0
  121. package/src/dev/runner-main.ts +3 -4
  122. package/src/dev/runner.ts +44 -13
  123. package/src/dev/supervisor.ts +54 -11
  124. package/src/internal/application-args.ts +13 -0
  125. package/src/internal/errors.ts +1 -1
  126. package/src/internal/run-cli-tool.ts +22 -0
  127. package/src/internal.ts +2 -0
  128. package/src/mcp/client.ts +1 -1
  129. package/src/mcp/handlers.ts +123 -56
  130. package/src/mcp/index.ts +1 -0
  131. package/src/mcp/serialize-widget.ts +71 -21
  132. package/src/mcp/widget-properties.ts +153 -0
  133. package/src/settings/compile.ts +8 -11
  134. package/src/vite-plugins/asset-emitter.ts +5 -0
  135. package/src/vite-plugins/asset-extensions.ts +1 -1
  136. package/src/vite-plugins/built-url.ts +19 -13
  137. package/src/vite-plugins/ensure-store.ts +14 -0
  138. package/src/vite-plugins/icons.ts +2 -5
  139. package/src/vite-plugins/react-compiler.ts +2 -1
  140. package/src/vite-plugins/resources.ts +10 -19
  141. package/src/vite-plugins/settings.ts +2 -2
  142. package/src/vite-plugins/undeclared-library.ts +1 -1
  143. package/src/vite-plugins/worker.ts +235 -0
  144. package/src/vitest-plugin.ts +15 -1
  145. package/dist/internal/resolve-cli-tool.d.ts +0 -3
  146. package/dist/internal/resolve-cli-tool.d.ts.map +0 -1
  147. package/dist/internal/resolve-cli-tool.js +0 -27
  148. package/dist/internal/resolve-cli-tool.js.map +0 -1
  149. package/src/internal/resolve-cli-tool.ts +0 -37
package/src/cli.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { packageVersion } from "@gtkx/utils";
2
2
  import { defineCommand, runMain } from "citty";
3
+ import { splitApplicationArgs } from "./internal/application-args.js";
3
4
  import { withErrorBoundary } from "./internal/errors.js";
4
5
 
5
- const version = packageVersion(import.meta.url);
6
+ const version = packageVersion(import.meta.url, "../package.json");
7
+ const { cliArgs } = splitApplicationArgs(process.argv.slice(2));
6
8
 
7
9
  const main = defineCommand({
8
10
  meta: {
@@ -39,4 +41,4 @@ const main = defineCommand({
39
41
  },
40
42
  });
41
43
 
42
- await runMain(main);
44
+ await runMain(main, { rawArgs: cliArgs });
@@ -27,8 +27,6 @@ const namespaceBarrelPath = (giStoreDir: string, library: string): string => {
27
27
  return join(giStoreDir, namespace, "index.js");
28
28
  };
29
29
 
30
- // The manifest is what makes the store resolvable as a package, and npm's prune of a generated store
31
- // removes the link while leaving the directory behind, so both have to be checked.
32
30
  const hasManifest = (storeDir: string): boolean => existsSync(join(storeDir, "package.json"));
33
31
 
34
32
  const isGiStoreStale = (store: CodegenStore, libraries: string[]): boolean => {
@@ -200,6 +200,7 @@ const getRunOptions = ({ root, mode, inputs, resolved }: RunOptionsInput): RunCo
200
200
  return { cwd: root, mode, inputs, resolved };
201
201
  };
202
202
 
203
+ /* eslint-disable-next-line unicorn/consistent-boolean-name -- the boolean reports whether codegen ran */
203
204
  const ensureGenerated = async (
204
205
  cwd: string,
205
206
  options: { shouldAnnounce?: boolean; mode?: string } = {},
@@ -245,6 +246,5 @@ export {
245
246
  syncSchemaEnv,
246
247
  ensureGenerated,
247
248
  resolveConfigWatch,
248
- type RunCodegenOptions,
249
249
  type RunCodegenResult,
250
250
  };
@@ -83,4 +83,4 @@ const resolveCodegenContext = async (cwd: string, mode?: string): Promise<Codege
83
83
  return { root: cwd, config, configFile };
84
84
  };
85
85
 
86
- export { resolveCodegenStore, resolveCodegenContext, type CodegenStore, type CodegenContext };
86
+ export { resolveCodegenStore, resolveCodegenContext, type CodegenStore };
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { ensureGenerated, resolveConfigWatch } from "../codegen/run-codegen.js";
3
3
  import { type DevWatch, runDevSupervisor } from "../dev/supervisor.js";
4
+ import { splitApplicationArgs } from "../internal/application-args.js";
4
5
  import { entryArg, resolveEntry } from "../internal/entry-arg.js";
5
6
 
6
7
  const DEV_MODE = "development";
@@ -17,7 +18,8 @@ const dev = defineCommand({
17
18
  const { cwd, entry: entryPath } = resolveEntry(args);
18
19
  await ensureGenerated(cwd, { shouldAnnounce: true, mode: DEV_MODE });
19
20
  const watch: DevWatch | undefined = await resolveConfigWatch(cwd, DEV_MODE);
20
- await runDevSupervisor(entryPath, cwd, watch);
21
+ const { applicationArgs } = splitApplicationArgs(process.argv.slice(2));
22
+ await runDevSupervisor({ entryPath, cwd, args: applicationArgs, watch });
21
23
  },
22
24
  });
23
25
 
@@ -0,0 +1,3 @@
1
+ const DEV_ENTRY_ENV = "GTKX_DEV_ENTRY";
2
+
3
+ export { DEV_ENTRY_ENV };
@@ -45,6 +45,7 @@ const defaultDevRunnerDeps = (): DevRunnerDeps => ({
45
45
  watchApplicationShutdown: (onShutdown) => {
46
46
  Gio.Application.getDefault()?.on("shutdown", onShutdown);
47
47
  },
48
+ isApplicationRegistered: () => Gio.Application.getDefault()?.getIsRegistered() === true,
48
49
  installShutdownHandlers: (onSignal) => {
49
50
  installGracefulShutdown({ onSignal });
50
51
  },
@@ -1,12 +1,11 @@
1
1
  import { error } from "@gtkx/utils";
2
2
  import { resolve } from "node:path";
3
3
  import { resolveDataDir } from "../internal/data-dir.js";
4
+ import { DEV_ENTRY_ENV } from "./entry-env.js";
4
5
  import { prepareDevIconDir } from "./icon-dir.js";
5
6
  import { createDevRunner } from "./runner.js";
6
7
  import { prepareDevSchemaDir } from "./schema-dir.js";
7
8
 
8
- const ENTRY_ARG_INDEX = 2;
9
-
10
9
  const main = async (): Promise<void> => {
11
10
  if (process.channel) {
12
11
  process.once("disconnect", () => {
@@ -15,10 +14,10 @@ const main = async (): Promise<void> => {
15
14
  }
16
15
 
17
16
  const cwd = process.cwd();
18
- const entryArg = process.argv[ENTRY_ARG_INDEX];
17
+ const entryArg = process.env[DEV_ENTRY_ENV];
19
18
 
20
19
  if (!entryArg) {
21
- error("Missing entry argument");
20
+ error(`Missing ${DEV_ENTRY_ENV}`);
22
21
  process.exit(1);
23
22
  }
24
23
 
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,6 +25,7 @@ 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;
@@ -33,15 +35,45 @@ type SupervisorState = {
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) {
@@ -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) => {
@@ -247,16 +285,14 @@ 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,
@@ -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 }) =>
@@ -43,20 +47,22 @@ const HANDLERS: Record<ServerInitiatedMethod, ValidatedHandler> = {
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();