@gtkx/cli 2.0.0-beta.3 → 2.0.0-beta.5

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 (88) hide show
  1. package/dist/cli.js +4 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/codegen/config-dependencies.d.ts +3 -0
  4. package/dist/codegen/config-dependencies.d.ts.map +1 -0
  5. package/dist/codegen/config-dependencies.js +355 -0
  6. package/dist/codegen/config-dependencies.js.map +1 -0
  7. package/dist/codegen/run-codegen.d.ts +3 -2
  8. package/dist/codegen/run-codegen.d.ts.map +1 -1
  9. package/dist/codegen/run-codegen.js +31 -6
  10. package/dist/codegen/run-codegen.js.map +1 -1
  11. package/dist/codegen/store-resolver.d.ts +1 -0
  12. package/dist/codegen/store-resolver.d.ts.map +1 -1
  13. package/dist/codegen/store-resolver.js +4 -2
  14. package/dist/codegen/store-resolver.js.map +1 -1
  15. package/dist/commands/cleanup.d.ts +9 -0
  16. package/dist/commands/cleanup.d.ts.map +1 -0
  17. package/dist/commands/cleanup.js +31 -0
  18. package/dist/commands/cleanup.js.map +1 -0
  19. package/dist/commands/dev.d.ts.map +1 -1
  20. package/dist/commands/dev.js +4 -2
  21. package/dist/commands/dev.js.map +1 -1
  22. package/dist/deploy/freedesktop/validate.d.ts.map +1 -1
  23. package/dist/deploy/freedesktop/validate.js +45 -12
  24. package/dist/deploy/freedesktop/validate.js.map +1 -1
  25. package/dist/deploy/payload/stage.d.ts.map +1 -1
  26. package/dist/deploy/payload/stage.js +24 -3
  27. package/dist/deploy/payload/stage.js.map +1 -1
  28. package/dist/deploy/settings/extra-files.d.ts.map +1 -1
  29. package/dist/deploy/settings/extra-files.js +4 -3
  30. package/dist/deploy/settings/extra-files.js.map +1 -1
  31. package/dist/dev/catalog-writes.d.ts +8 -0
  32. package/dist/dev/catalog-writes.d.ts.map +1 -0
  33. package/dist/dev/catalog-writes.js +26 -0
  34. package/dist/dev/catalog-writes.js.map +1 -0
  35. package/dist/dev/runner-deps.d.ts.map +1 -1
  36. package/dist/dev/runner-deps.js +10 -6
  37. package/dist/dev/runner-deps.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 +8 -2
  41. package/dist/dev/runner.js.map +1 -1
  42. package/dist/dev/supervisor.d.ts +2 -1
  43. package/dist/dev/supervisor.d.ts.map +1 -1
  44. package/dist/dev/supervisor.js +128 -26
  45. package/dist/dev/supervisor.js.map +1 -1
  46. package/dist/i18n/catalogs.d.ts +6 -2
  47. package/dist/i18n/catalogs.d.ts.map +1 -1
  48. package/dist/i18n/catalogs.js +9 -7
  49. package/dist/i18n/catalogs.js.map +1 -1
  50. package/dist/i18n/source-messages.d.ts +3 -1
  51. package/dist/i18n/source-messages.d.ts.map +1 -1
  52. package/dist/i18n/source-messages.js +14 -2
  53. package/dist/i18n/source-messages.js.map +1 -1
  54. package/dist/internal/build-output.d.ts.map +1 -1
  55. package/dist/internal/build-output.js +45 -8
  56. package/dist/internal/build-output.js.map +1 -1
  57. package/dist/internal/prepare-project.d.ts +1 -0
  58. package/dist/internal/prepare-project.d.ts.map +1 -1
  59. package/dist/internal/prepare-project.js +8 -2
  60. package/dist/internal/prepare-project.js.map +1 -1
  61. package/dist/vite-plugins/i18n.d.ts +11 -2
  62. package/dist/vite-plugins/i18n.d.ts.map +1 -1
  63. package/dist/vite-plugins/i18n.js +20 -6
  64. package/dist/vite-plugins/i18n.js.map +1 -1
  65. package/dist/vite-plugins/index.d.ts +2 -0
  66. package/dist/vite-plugins/index.d.ts.map +1 -1
  67. package/dist/vite-plugins/index.js +10 -2
  68. package/dist/vite-plugins/index.js.map +1 -1
  69. package/package.json +12 -10
  70. package/src/cli.ts +5 -0
  71. package/src/codegen/config-dependencies.ts +470 -0
  72. package/src/codegen/run-codegen.ts +37 -5
  73. package/src/codegen/store-resolver.ts +5 -2
  74. package/src/commands/cleanup.ts +36 -0
  75. package/src/commands/dev.ts +9 -2
  76. package/src/deploy/freedesktop/validate.ts +63 -13
  77. package/src/deploy/payload/stage.ts +42 -3
  78. package/src/deploy/settings/extra-files.ts +5 -3
  79. package/src/dev/catalog-writes.ts +35 -0
  80. package/src/dev/runner-deps.ts +14 -6
  81. package/src/dev/runner.ts +11 -2
  82. package/src/dev/supervisor.ts +177 -29
  83. package/src/i18n/catalogs.ts +20 -8
  84. package/src/i18n/source-messages.ts +19 -3
  85. package/src/internal/build-output.ts +67 -9
  86. package/src/internal/prepare-project.ts +9 -3
  87. package/src/vite-plugins/i18n.ts +42 -9
  88. package/src/vite-plugins/index.ts +19 -3
@@ -6,6 +6,12 @@ type ToolResult = {
6
6
  status: number;
7
7
  };
8
8
 
9
+ type Diagnostic = {
10
+ severity: string;
11
+ rule: string;
12
+ detail: string;
13
+ };
14
+
9
15
  type MetainfoResult = {
10
16
  subject: string;
11
17
  output: string;
@@ -13,6 +19,7 @@ type MetainfoResult = {
13
19
  errors: string[];
14
20
  warnings: string[];
15
21
  rules: string[];
22
+ notes: string[];
16
23
  areWarningsFatal: boolean;
17
24
  };
18
25
 
@@ -22,12 +29,19 @@ type ToolRequest = {
22
29
  subject: string;
23
30
  };
24
31
 
25
- const DIAGNOSTIC = /^(?<severity>[EIPW]): \S+ (?<rule>[\w-]+)/;
32
+ const DIAGNOSTIC = /^(?<severity>[EIPW]): \S+ (?<rule>[\w-]+)(?: (?<detail>.*))?$/;
33
+ const SUCCESS_SUMMARY = /^\W*Validation was successful/u;
26
34
  const ERROR_SEVERITY = "E";
27
35
  const WARNING_SEVERITY = "W";
28
36
  const INFO_SEVERITY = "I";
29
37
  const FATAL_WARNING_RULES: Set<string> = new Set(["unknown-tag"]);
30
38
 
39
+ const FATAL_RULE_NOTES: Record<string, (detail: string) => string> = {
40
+ "unknown-tag": (element) =>
41
+ "GTKX treats unknown-tag as fatal for every target, whatever severity appstreamcli assigns it; " +
42
+ `the unsupported element is <${element}>`,
43
+ };
44
+
31
45
  const REMEDY_FOR_RULE: Record<string, string> = {
32
46
  "component-summary-missing": "set `deploy.summary`",
33
47
  "description-first-para-too-short": "open `deploy.description` with a paragraph longer than 80 characters",
@@ -35,6 +49,7 @@ const REMEDY_FOR_RULE: Record<string, string> = {
35
49
  "metainfo-legacy-path": "this file is generated; report it as a gtkx bug",
36
50
  "summary-has-dot-suffix": "drop the trailing period from `deploy.summary`",
37
51
  "summary-too-long": "shorten `deploy.summary`",
52
+ "unknown-tag": "remove the unsupported `deploy.metainfoExtra` element or use a `<custom>` value",
38
53
  "url-homepage-missing": "set `deploy.homepage`, or `homepage` in package.json",
39
54
  };
40
55
 
@@ -48,12 +63,31 @@ const runTool = ({ tool, args, subject }: ToolRequest): ToolResult => {
48
63
  return { output: [result.stdout, result.stderr].join("\n").trim(), status: result.status ?? 0 };
49
64
  };
50
65
 
51
- const rulesIn = (output: string, severities: string[]): string[] =>
66
+ const parseDiagnostics = (output: string): Diagnostic[] =>
67
+ output.split("\n").flatMap((line) => {
68
+ const groups = DIAGNOSTIC.exec(line)?.groups;
69
+
70
+ return groups === undefined
71
+ ? []
72
+ : [{ severity: groups.severity ?? "", rule: groups.rule ?? "", detail: (groups.detail ?? "").trim() }];
73
+ });
74
+
75
+ const rulesIn = (diagnostics: Diagnostic[], severities: string[]): string[] =>
76
+ diagnostics.filter((diagnostic) => severities.includes(diagnostic.severity)).map((diagnostic) => diagnostic.rule);
77
+
78
+ const fatalNotes = (diagnostics: Diagnostic[]): string[] =>
79
+ diagnostics
80
+ .filter((diagnostic) => diagnostic.severity !== ERROR_SEVERITY && FATAL_WARNING_RULES.has(diagnostic.rule))
81
+ .map((diagnostic) =>
82
+ FATAL_RULE_NOTES[diagnostic.rule]?.(diagnostic.detail) ??
83
+ `GTKX treats ${diagnostic.rule} as fatal for every target`);
84
+
85
+ const withoutSuccessSummary = (output: string): string =>
52
86
  output
53
87
  .split("\n")
54
- .map((line) => DIAGNOSTIC.exec(line)?.groups)
55
- .filter((groups) => groups !== undefined && severities.includes(groups.severity ?? ""))
56
- .map((groups) => groups?.rule ?? "");
88
+ .filter((line) => !SUCCESS_SUMMARY.test(line))
89
+ .join("\n")
90
+ .trimEnd();
57
91
 
58
92
  const remedyLines = (rules: string[]): string[] => {
59
93
  const remedies = sortStrings([...new Set(rules)])
@@ -63,8 +97,13 @@ const remedyLines = (rules: string[]): string[] => {
63
97
  return remedies.length === 0 ? [] : ["", "Fix it in gtkx.config.ts:", ...remedies];
64
98
  };
65
99
 
66
- const invalid = (subject: string, output: string, rules: string[]): Error =>
67
- new Error([`${subject} is not valid:`, output.length > 0 ? output : "no output", ...remedyLines(rules)].join("\n"));
100
+ const invalid = (subject: string, output: string, rules: string[], notes: string[]): Error =>
101
+ new Error([
102
+ `${subject} is not valid:`,
103
+ output.length > 0 ? output : "no output",
104
+ ...notes,
105
+ ...remedyLines(rules),
106
+ ].join("\n"));
68
107
 
69
108
  const isFatalResult = ({ status, errors, warnings, rules, areWarningsFatal }: MetainfoResult): boolean => {
70
109
  if (errors.length > 0) {
@@ -84,7 +123,7 @@ const isFatalResult = ({ status, errors, warnings, rules, areWarningsFatal }: Me
84
123
 
85
124
  const assertNotFatal = (result: MetainfoResult): void => {
86
125
  if (isFatalResult(result)) {
87
- throw invalid(result.subject, result.output, result.rules);
126
+ throw invalid(result.subject, result.output, result.rules, result.notes);
88
127
  }
89
128
  };
90
129
 
@@ -108,7 +147,7 @@ const validateDesktopEntry = (path: string): void => {
108
147
  });
109
148
 
110
149
  if (status !== 0 || output.length > 0) {
111
- throw invalid("The desktop entry", output, []);
150
+ throw invalid("The desktop entry", output, [], []);
112
151
  }
113
152
  };
114
153
 
@@ -121,11 +160,22 @@ const validateMetainfo = (path: string, areWarningsFatal: boolean): void => {
121
160
  subject,
122
161
  });
123
162
 
124
- const errors = rulesIn(output, [ERROR_SEVERITY]);
125
- const warnings = rulesIn(output, [WARNING_SEVERITY]);
126
- const infos = rulesIn(output, [INFO_SEVERITY]);
163
+ const diagnostics = parseDiagnostics(output);
164
+ const errors = rulesIn(diagnostics, [ERROR_SEVERITY]);
165
+ const warnings = rulesIn(diagnostics, [WARNING_SEVERITY]);
166
+ const infos = rulesIn(diagnostics, [INFO_SEVERITY]);
127
167
  const rules = [...errors, ...warnings, ...infos];
128
- assertNotFatal({ subject, output, status, errors, warnings, rules, areWarningsFatal });
168
+ const notes = fatalNotes(diagnostics);
169
+ assertNotFatal({
170
+ subject,
171
+ output: withoutSuccessSummary(output),
172
+ status,
173
+ errors,
174
+ warnings,
175
+ rules,
176
+ notes,
177
+ areWarningsFatal,
178
+ });
129
179
  reportDiagnostics(subject, [...warnings, ...infos]);
130
180
  };
131
181
 
@@ -1,4 +1,4 @@
1
- import { sortStringsBy } from "@gtkx/utils";
1
+ import { sortStringsBy, warn } from "@gtkx/utils";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import type { DeploySettings, DeployTargetName, NodeRuntime, NoticeSection, StagedFile } from "../types.js";
@@ -82,9 +82,48 @@ const stageMetadata = (settings: DeploySettings, root: string, metadata: StagedM
82
82
  : [writeInto(root, join(SHARE_MIME_PACKAGES, `${settings.applicationId}.xml`), metadata.mimePackage)]),
83
83
  ];
84
84
 
85
- const stageExtraFiles = (settings: DeploySettings, root: string): StagedFile[] =>
85
+ const generatedMetadataKind = (
86
+ settings: DeploySettings,
87
+ metadata: StagedMetadata,
88
+ destination: string,
89
+ ): string | null => {
90
+ if (destination === join(SHARE_APPLICATIONS, `${settings.applicationId}.desktop`)) {
91
+ return "desktop entry";
92
+ }
93
+
94
+ if (destination === join(SHARE_METAINFO, `${settings.applicationId}.metainfo.xml`)) {
95
+ return "metainfo";
96
+ }
97
+
98
+ if (
99
+ metadata.mimePackage !== null &&
100
+ destination === join(SHARE_MIME_PACKAGES, `${settings.applicationId}.xml`)
101
+ ) {
102
+ return "MIME package";
103
+ }
104
+
105
+ return null;
106
+ };
107
+
108
+ const warnMetadataCollision = (
109
+ settings: DeploySettings,
110
+ metadata: StagedMetadata,
111
+ file: DeploySettings["extraFiles"][number],
112
+ ): void => {
113
+ const kind = generatedMetadataKind(settings, metadata, file.destination);
114
+
115
+ if (kind !== null) {
116
+ warn(
117
+ `extraFiles source "${file.source}" targeting "${file.destination}" is overridden by the ` +
118
+ `GTKX-generated ${kind}`,
119
+ );
120
+ }
121
+ };
122
+
123
+ const stageExtraFiles = (settings: DeploySettings, root: string, metadata: StagedMetadata): StagedFile[] =>
86
124
  settings.extraFiles.map((file) => {
87
125
  const source = resolve(settings.paths.root, file.source);
126
+ warnMetadataCollision(settings, metadata, file);
88
127
 
89
128
  return copyInto(root, file.destination, source, file.mode ?? executableModeFor(source));
90
129
  });
@@ -108,7 +147,7 @@ const stagePayload = ({ settings, node, metadata }: StageRequest): StagedFile[]
108
147
  ...stageNodeBinary(settings, root, node),
109
148
  ...stageRuntimeFiles(settings, root),
110
149
  ...stageCatalogs(settings, root),
111
- ...stageExtraFiles(settings, root),
150
+ ...stageExtraFiles(settings, root, metadata),
112
151
  ...stageMetadata(settings, root, metadata),
113
152
  ...stageIcons(settings, root),
114
153
  ...stageSchemas(settings, root),
@@ -1,5 +1,5 @@
1
1
  import { statSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { normalize, resolve } from "node:path";
3
3
  import type { DeployConfig, DeployExtraFile } from "../types.js";
4
4
 
5
5
  type ExtraFiles = NonNullable<DeployConfig["extraFiles"]>;
@@ -22,13 +22,15 @@ const assertSourceFile = (root: string, file: DeployExtraFile): void => {
22
22
  };
23
23
 
24
24
  const resolveExtraFile = (destination: string, entry: ExtraFileEntry): DeployExtraFile => {
25
+ const normalizedDestination = normalize(destination);
26
+
25
27
  if (typeof entry === "string") {
26
- return { destination, source: entry, mode: null };
28
+ return { destination: normalizedDestination, source: entry, mode: null };
27
29
  }
28
30
 
29
31
  const mode = entry.mode === undefined ? null : Number.parseInt(entry.mode, OCTAL_RADIX);
30
32
 
31
- return { destination, source: entry.source, mode };
33
+ return { destination: normalizedDestination, source: entry.source, mode };
32
34
  };
33
35
 
34
36
  const resolveExtraFiles = (root: string, deploy: DeployConfig): DeployExtraFile[] => {
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { WrittenCatalog } from "../i18n/catalogs.js";
4
+
5
+ type CatalogWrites = {
6
+ record: (catalogs: WrittenCatalog[]) => void;
7
+ hasWritten: (path: string) => boolean;
8
+ };
9
+
10
+ const hasContent = (path: string, content: Buffer): boolean => {
11
+ try {
12
+ return readFileSync(path).equals(content);
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+
18
+ const createCatalogWrites = (): CatalogWrites => {
19
+ const contents: Map<string, Buffer> = new Map();
20
+
21
+ return {
22
+ record: (catalogs) => {
23
+ for (const catalog of catalogs) {
24
+ contents.set(resolve(catalog.path), catalog.content);
25
+ }
26
+ },
27
+ hasWritten: (path) => {
28
+ const content = contents.get(resolve(path));
29
+
30
+ return content !== undefined && hasContent(path, content);
31
+ },
32
+ };
33
+ };
34
+
35
+ export { type CatalogWrites, createCatalogWrites };
@@ -17,6 +17,7 @@ import { isRefreshBoundary, performRefresh, staleExportName } from "../refresh-r
17
17
  import { gtkxFastRefresh } from "../vite-plugins/fast-refresh/swc-refresh.js";
18
18
  import { gtkxVitePlugins } from "../vite-plugins/index.js";
19
19
  import { gtkxReactDomPrebundle } from "../vite-plugins/react-dom-prebundle.js";
20
+ import { type CatalogWrites, createCatalogWrites } from "./catalog-writes.js";
20
21
 
21
22
  const DEV_MODE = "development";
22
23
  const APPLICATION_POLL_INTERVAL_MS = 50;
@@ -43,7 +44,14 @@ const waitForApplicationId = async (timeoutMs: number, shouldKeepWaiting: () =>
43
44
 
44
45
  const readFileRevision = (path: string): Promise<string> => readFile(path, "utf8");
45
46
 
46
- const defaultDevRunnerDeps = (configFile: string): DevRunnerDeps => ({
47
+ const devPlugins = (configFile: string, catalogWrites: CatalogWrites): DevRunnerDeps["plugins"] =>
48
+ (entryPath) => [
49
+ ...gtkxVitePlugins({ mode: DEV_MODE, entryPath, configFile, onCatalogsWritten: catalogWrites.record }),
50
+ ...gtkxFastRefresh(),
51
+ gtkxReactDomPrebundle(),
52
+ ];
53
+
54
+ const createDevRunnerDeps = (configFile: string, catalogWrites: CatalogWrites): DevRunnerDeps => ({
47
55
  createServer,
48
56
  waitForApplicationId,
49
57
  getConfiguredApplicationId: async (root: string) => {
@@ -86,13 +94,13 @@ const defaultDevRunnerDeps = (configFile: string): DevRunnerDeps => ({
86
94
  isRefreshBoundary,
87
95
  staleExportName,
88
96
  readFileRevision,
89
- plugins: (entryPath) => [
90
- ...gtkxVitePlugins({ mode: DEV_MODE, entryPath, configFile }),
91
- ...gtkxFastRefresh(),
92
- gtkxReactDomPrebundle(),
93
- ],
97
+ hasWrittenCatalog: catalogWrites.hasWritten,
98
+ plugins: devPlugins(configFile, catalogWrites),
94
99
  log: info,
95
100
  exit: (code: number): never => process.exit(code),
96
101
  });
97
102
 
103
+ const defaultDevRunnerDeps = (configFile: string): DevRunnerDeps =>
104
+ createDevRunnerDeps(configFile, createCatalogWrites());
105
+
98
106
  export { defaultDevRunnerDeps };
package/src/dev/runner.ts CHANGED
@@ -33,6 +33,7 @@ type DevRunnerDeps = {
33
33
  isRefreshBoundary(module: Record<string, unknown>): boolean;
34
34
  staleExportName(previous: Record<string, unknown>, current: Record<string, unknown>): string | null;
35
35
  readFileRevision(path: string): Promise<string>;
36
+ hasWrittenCatalog(path: string): boolean;
36
37
  plugins(entryPath: string): Plugin[];
37
38
  log(message: string): void;
38
39
  exit(code: number): never;
@@ -370,14 +371,22 @@ const restartForServerConfig = async (session: DevSession, changedPath: string):
370
371
  await requestRestart(session);
371
372
  };
372
373
 
374
+ const restartForCatalog = async (session: DevSession, changedPath: string): Promise<void> => {
375
+ if (session.deps.hasWrittenCatalog(changedPath)) {
376
+ return;
377
+ }
378
+
379
+ session.deps.log(`Translation catalog changed: ${changedPath}`);
380
+ await requestRestart(session);
381
+ };
382
+
373
383
  const applyChange = async (session: DevSession, change: WatchedChange): Promise<void> => {
374
384
  if (session.controller.isShuttingDown()) {
375
385
  return;
376
386
  }
377
387
 
378
388
  if (isCatalogSource(session.server.config.root, change.path)) {
379
- session.deps.log(`Translation catalog changed: ${change.path}`);
380
- await requestRestart(session);
389
+ await restartForCatalog(session, change.path);
381
390
 
382
391
  return;
383
392
  }
@@ -1,7 +1,7 @@
1
1
  import { error, exitCodeForSignal, info, installGracefulShutdown } from "@gtkx/utils";
2
2
  import { fork as nodeFork } from "node:child_process";
3
- import { type FSWatcher, watch as watchFs } from "node:fs";
4
- import { basename, dirname } from "node:path";
3
+ import { type FSWatcher, statSync, watch as watchFs } from "node:fs";
4
+ import { basename, dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEV_CONFIG_ENV, DEV_ENTRY_ENV } from "./entry-env.js";
7
7
 
@@ -18,7 +18,8 @@ type ForkRunner = (modulePath: string, args: string[], env: NodeJS.ProcessEnv, c
18
18
 
19
19
  type DevWatch = {
20
20
  paths: string[];
21
- regenerate: () => Promise<void>;
21
+ resolvePaths: () => string[];
22
+ regenerate: () => Promise<string[]>;
22
23
  };
23
24
 
24
25
  type SupervisorState = {
@@ -29,10 +30,13 @@ type SupervisorState = {
29
30
  args: string[];
30
31
  watch: DevWatch | undefined;
31
32
  watchers: FSWatcher[];
33
+ changedPaths: Set<string>;
34
+ restartTimer: DebounceTimer;
32
35
  fork: ForkRunner;
33
36
  child: SupervisedChild | null;
34
37
  isShuttingDown: boolean;
35
38
  isRestarting: boolean;
39
+ isRestartPending: boolean;
36
40
  capturedChildExit: number | undefined;
37
41
  };
38
42
 
@@ -141,39 +145,94 @@ const launch = (state: SupervisorState): void => {
141
145
  });
142
146
  };
143
147
 
144
- const isRestartBlocked = (state: SupervisorState): boolean => state.isRestarting || state.isShuttingDown;
145
-
146
- const relaunchAfterExit = (state: SupervisorState): void => {
148
+ const runPendingRestart = (state: SupervisorState): void => {
147
149
  state.isRestarting = false;
148
150
 
151
+ if (!state.isRestartPending || state.isShuttingDown) {
152
+ return;
153
+ }
154
+
155
+ state.isRestartPending = false;
156
+ void restart(state);
157
+ };
158
+
159
+ const relaunchAfterExit = (state: SupervisorState): void => {
149
160
  if (state.isShuttingDown) {
161
+ state.isRestarting = false;
162
+
150
163
  return;
151
164
  }
152
165
 
153
166
  info("Restarting dev runner...");
154
167
  launch(state);
168
+ runPendingRestart(state);
169
+ };
170
+
171
+ const isSupervisorShuttingDown = (state: SupervisorState): boolean => state.isShuttingDown;
172
+
173
+ const reconcileConfigWatchers = (
174
+ state: SupervisorState,
175
+ paths: string[],
176
+ previous: ReadonlyMap<string, string | null>,
177
+ ): void => {
178
+ const changed = changedWatchPaths(previous, paths);
179
+
180
+ closeWatchers(state);
181
+
182
+ if (state.watch !== undefined) {
183
+ state.watch.paths = paths;
184
+ }
185
+
186
+ if (state.isShuttingDown) {
187
+ return;
188
+ }
189
+
190
+ installConfigWatchers(state);
191
+
192
+ if (changed.length > 0 && !state.isRestartPending) {
193
+ scheduleRestart(state, changed);
194
+ }
195
+ };
196
+
197
+ const consumeChangedPaths = (state: SupervisorState): string => {
198
+ const changed = [...state.changedPaths].map((path) => relative(state.cwd, path));
199
+ state.changedPaths.clear();
200
+
201
+ return changed.length === 0 ? basename(state.configFile) : changed.join(", ");
155
202
  };
156
203
 
157
204
  const restart = async (state: SupervisorState): Promise<void> => {
158
205
  const watch = state.watch;
159
206
 
160
- if (watch === undefined || isRestartBlocked(state)) {
207
+ if (watch === undefined || state.isShuttingDown) {
208
+ return;
209
+ }
210
+
211
+ if (state.isRestarting) {
212
+ state.isRestartPending = true;
213
+
161
214
  return;
162
215
  }
163
216
 
164
217
  state.isRestarting = true;
165
- info("gtkx.config.ts changed; regenerating bindings...");
218
+ info(`${consumeChangedPaths(state)} changed; regenerating bindings...`);
219
+ closeWatchers(state);
220
+ watch.paths = watch.resolvePaths();
221
+ const previous = snapshotWatchPaths(watch.paths);
222
+ installConfigWatchers(state);
166
223
 
167
224
  try {
168
- await watch.regenerate();
225
+ const paths = await watch.regenerate();
226
+ reconcileConfigWatchers(state, paths, previous);
169
227
  } catch (error_) {
228
+ reconcileConfigWatchers(state, watch.resolvePaths(), previous);
170
229
  error("Codegen failed; keeping the current dev runner. Fix the error and save again.", error_);
171
- state.isRestarting = false;
230
+ runPendingRestart(state);
172
231
 
173
232
  return;
174
233
  }
175
234
 
176
- if (state.isShuttingDown) {
235
+ if (isSupervisorShuttingDown(state)) {
177
236
  state.isRestarting = false;
178
237
 
179
238
  return;
@@ -182,8 +241,8 @@ const restart = async (state: SupervisorState): Promise<void> => {
182
241
  const current = state.child;
183
242
 
184
243
  if (current === null) {
185
- state.isRestarting = false;
186
244
  launch(state);
245
+ runPendingRestart(state);
187
246
 
188
247
  return;
189
248
  }
@@ -195,29 +254,103 @@ const restart = async (state: SupervisorState): Promise<void> => {
195
254
  forwardSignal(current, "SIGTERM");
196
255
  };
197
256
 
198
- const scheduleRestart = (state: SupervisorState, timer: DebounceTimer): void => {
257
+ const scheduleRestart = (state: SupervisorState, changedPaths: string[] = []): void => {
258
+ const timer = state.restartTimer;
259
+
260
+ for (const path of changedPaths) {
261
+ state.changedPaths.add(path);
262
+ }
263
+
199
264
  if (timer.handle !== null) {
200
265
  clearTimeout(timer.handle);
201
266
  }
202
267
 
203
- timer.handle = setTimeout(() => void restart(state), CONFIG_DEBOUNCE_MS);
268
+ timer.handle = setTimeout(() => {
269
+ timer.handle = null;
270
+ void restart(state);
271
+ }, CONFIG_DEBOUNCE_MS);
204
272
  };
205
273
 
206
- const isWatchedChange = (state: SupervisorState, names: Set<string>, filename: string | Buffer | null): boolean => {
274
+ const watchedChangePath = (
275
+ state: SupervisorState,
276
+ directory: string,
277
+ names: Set<string>,
278
+ filename: string | Buffer | null,
279
+ ): string | undefined => {
207
280
  if (filename === null || state.isShuttingDown) {
281
+ return undefined;
282
+ }
283
+
284
+ const name = basename(filename.toString());
285
+
286
+ return names.has(name) ? join(directory, name) : undefined;
287
+ };
288
+
289
+ const isDirectory = (path: string): boolean => {
290
+ try {
291
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
292
+ } catch {
208
293
  return false;
209
294
  }
295
+ };
210
296
 
211
- return names.has(basename(filename.toString()));
297
+ const nearestWatchTarget = (path: string): { directory: string; name: string } => {
298
+ let directory = dirname(path);
299
+ let name = basename(path);
300
+
301
+ while (!isDirectory(directory)) {
302
+ const parent = dirname(directory);
303
+
304
+ if (parent === directory) {
305
+ break;
306
+ }
307
+
308
+ name = basename(directory);
309
+ directory = parent;
310
+ }
311
+
312
+ return { directory, name };
212
313
  };
213
314
 
315
+ const watchTargets = (paths: string[]): Set<string> =>
316
+ new Set(paths.flatMap((path) => {
317
+ const { directory, name } = nearestWatchTarget(path);
318
+
319
+ return [path, join(directory, name)];
320
+ }));
321
+
322
+ const watchPathState = (path: string): string | null => {
323
+ try {
324
+ const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
325
+
326
+ return stat === undefined
327
+ ? null
328
+ : [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
329
+ } catch {
330
+ return null;
331
+ }
332
+ };
333
+
334
+ const snapshotWatchPaths = (paths: string[]): Map<string, string | null> =>
335
+ new Map([...watchTargets(paths)].map((path) => [path, watchPathState(path)]));
336
+
337
+ const changedWatchPaths = (
338
+ previous: ReadonlyMap<string, string | null>,
339
+ paths: string[],
340
+ ): string[] =>
341
+ [...watchTargets(paths)].filter((path) => {
342
+ const current = watchPathState(path);
343
+
344
+ return previous.has(path) ? previous.get(path) !== current : current !== null;
345
+ });
346
+
214
347
  const groupWatchNamesByDirectory = (paths: string[]): Map<string, Set<string>> => {
215
348
  const namesByDirectory: Map<string, Set<string>> = new Map();
216
349
 
217
350
  for (const path of paths) {
218
- const directory = dirname(path);
351
+ const { directory, name } = nearestWatchTarget(path);
219
352
  const names = namesByDirectory.get(directory) ?? new Set<string>();
220
- names.add(basename(path));
353
+ names.add(name);
221
354
  namesByDirectory.set(directory, names);
222
355
  }
223
356
 
@@ -228,16 +361,23 @@ const watchConfigDirectory = (
228
361
  state: SupervisorState,
229
362
  directory: string,
230
363
  names: Set<string>,
231
- timer: DebounceTimer,
232
364
  ): void => {
233
- const watcher = watchFs(directory, (_event, filename) => {
234
- if (isWatchedChange(state, names, filename)) {
235
- scheduleRestart(state, timer);
236
- }
237
- });
365
+ try {
366
+ const watcher = watchFs(directory, (_event, filename) => {
367
+ const changed = watchedChangePath(state, directory, names, filename);
368
+
369
+ if (changed !== undefined) {
370
+ scheduleRestart(state, [changed]);
371
+ }
372
+ });
238
373
 
239
- watcher.on("error", (): void => undefined);
240
- state.watchers.push(watcher);
374
+ watcher.on("error", (cause) => {
375
+ error(`Configuration watch failed for ${directory}`, cause);
376
+ });
377
+ state.watchers.push(watcher);
378
+ } catch (error_) {
379
+ error(`Configuration watch failed for ${directory}`, error_);
380
+ }
241
381
  };
242
382
 
243
383
  const installConfigWatchers = (state: SupervisorState): void => {
@@ -247,10 +387,8 @@ const installConfigWatchers = (state: SupervisorState): void => {
247
387
  return;
248
388
  }
249
389
 
250
- const timer: DebounceTimer = { handle: null };
251
-
252
390
  for (const [directory, names] of groupWatchNamesByDirectory(watch.paths)) {
253
- watchConfigDirectory(state, directory, names, timer);
391
+ watchConfigDirectory(state, directory, names);
254
392
  }
255
393
  };
256
394
 
@@ -258,6 +396,8 @@ const closeWatchers = (state: SupervisorState): void => {
258
396
  for (const watcher of state.watchers) {
259
397
  watcher.close();
260
398
  }
399
+
400
+ state.watchers = [];
261
401
  };
262
402
 
263
403
  const shutdownOnSignal = (state: SupervisorState, signal: NodeJS.Signals): Promise<void> =>
@@ -265,6 +405,11 @@ const shutdownOnSignal = (state: SupervisorState, signal: NodeJS.Signals): Promi
265
405
  state.isShuttingDown = true;
266
406
  closeWatchers(state);
267
407
 
408
+ if (state.restartTimer.handle !== null) {
409
+ clearTimeout(state.restartTimer.handle);
410
+ state.restartTimer.handle = null;
411
+ }
412
+
268
413
  if (!state.child) {
269
414
  resolve();
270
415
 
@@ -298,10 +443,13 @@ const runDevSupervisor = async (options: DevSupervisorOptions): Promise<never> =
298
443
  args,
299
444
  watch,
300
445
  watchers: [],
446
+ changedPaths: new Set(),
447
+ restartTimer: { handle: null },
301
448
  fork,
302
449
  child: null,
303
450
  isShuttingDown: false,
304
451
  isRestarting: false,
452
+ isRestartPending: false,
305
453
  capturedChildExit: undefined,
306
454
  };
307
455