@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.
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen/config-dependencies.d.ts +3 -0
- package/dist/codegen/config-dependencies.d.ts.map +1 -0
- package/dist/codegen/config-dependencies.js +355 -0
- package/dist/codegen/config-dependencies.js.map +1 -0
- package/dist/codegen/run-codegen.d.ts +3 -2
- package/dist/codegen/run-codegen.d.ts.map +1 -1
- package/dist/codegen/run-codegen.js +31 -6
- package/dist/codegen/run-codegen.js.map +1 -1
- package/dist/codegen/store-resolver.d.ts +1 -0
- package/dist/codegen/store-resolver.d.ts.map +1 -1
- package/dist/codegen/store-resolver.js +4 -2
- package/dist/codegen/store-resolver.js.map +1 -1
- package/dist/commands/cleanup.d.ts +9 -0
- package/dist/commands/cleanup.d.ts.map +1 -0
- package/dist/commands/cleanup.js +31 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +4 -2
- package/dist/commands/dev.js.map +1 -1
- package/dist/deploy/freedesktop/validate.d.ts.map +1 -1
- package/dist/deploy/freedesktop/validate.js +45 -12
- package/dist/deploy/freedesktop/validate.js.map +1 -1
- package/dist/deploy/payload/stage.d.ts.map +1 -1
- package/dist/deploy/payload/stage.js +24 -3
- package/dist/deploy/payload/stage.js.map +1 -1
- package/dist/deploy/settings/extra-files.d.ts.map +1 -1
- package/dist/deploy/settings/extra-files.js +4 -3
- package/dist/deploy/settings/extra-files.js.map +1 -1
- package/dist/dev/catalog-writes.d.ts +8 -0
- package/dist/dev/catalog-writes.d.ts.map +1 -0
- package/dist/dev/catalog-writes.js +26 -0
- package/dist/dev/catalog-writes.js.map +1 -0
- package/dist/dev/runner-deps.d.ts.map +1 -1
- package/dist/dev/runner-deps.js +10 -6
- package/dist/dev/runner-deps.js.map +1 -1
- package/dist/dev/runner.d.ts +1 -0
- package/dist/dev/runner.d.ts.map +1 -1
- package/dist/dev/runner.js +8 -2
- package/dist/dev/runner.js.map +1 -1
- package/dist/dev/supervisor.d.ts +2 -1
- package/dist/dev/supervisor.d.ts.map +1 -1
- package/dist/dev/supervisor.js +128 -26
- package/dist/dev/supervisor.js.map +1 -1
- package/dist/i18n/catalogs.d.ts +6 -2
- package/dist/i18n/catalogs.d.ts.map +1 -1
- package/dist/i18n/catalogs.js +9 -7
- package/dist/i18n/catalogs.js.map +1 -1
- package/dist/i18n/source-messages.d.ts +3 -1
- package/dist/i18n/source-messages.d.ts.map +1 -1
- package/dist/i18n/source-messages.js +14 -2
- package/dist/i18n/source-messages.js.map +1 -1
- package/dist/internal/build-output.d.ts.map +1 -1
- package/dist/internal/build-output.js +45 -8
- package/dist/internal/build-output.js.map +1 -1
- package/dist/internal/prepare-project.d.ts +1 -0
- package/dist/internal/prepare-project.d.ts.map +1 -1
- package/dist/internal/prepare-project.js +8 -2
- package/dist/internal/prepare-project.js.map +1 -1
- package/dist/vite-plugins/i18n.d.ts +11 -2
- package/dist/vite-plugins/i18n.d.ts.map +1 -1
- package/dist/vite-plugins/i18n.js +20 -6
- package/dist/vite-plugins/i18n.js.map +1 -1
- package/dist/vite-plugins/index.d.ts +2 -0
- package/dist/vite-plugins/index.d.ts.map +1 -1
- package/dist/vite-plugins/index.js +10 -2
- package/dist/vite-plugins/index.js.map +1 -1
- package/package.json +12 -10
- package/src/cli.ts +5 -0
- package/src/codegen/config-dependencies.ts +470 -0
- package/src/codegen/run-codegen.ts +37 -5
- package/src/codegen/store-resolver.ts +5 -2
- package/src/commands/cleanup.ts +36 -0
- package/src/commands/dev.ts +9 -2
- package/src/deploy/freedesktop/validate.ts +63 -13
- package/src/deploy/payload/stage.ts +42 -3
- package/src/deploy/settings/extra-files.ts +5 -3
- package/src/dev/catalog-writes.ts +35 -0
- package/src/dev/runner-deps.ts +14 -6
- package/src/dev/runner.ts +11 -2
- package/src/dev/supervisor.ts +177 -29
- package/src/i18n/catalogs.ts +20 -8
- package/src/i18n/source-messages.ts +19 -3
- package/src/internal/build-output.ts +67 -9
- package/src/internal/prepare-project.ts +9 -3
- package/src/vite-plugins/i18n.ts +42 -9
- 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
|
|
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
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.
|
|
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([
|
|
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
|
|
125
|
-
const
|
|
126
|
-
const
|
|
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
|
-
|
|
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
|
|
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 };
|
package/src/dev/runner-deps.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
380
|
-
await requestRestart(session);
|
|
389
|
+
await restartForCatalog(session, change.path);
|
|
381
390
|
|
|
382
391
|
return;
|
|
383
392
|
}
|
package/src/dev/supervisor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ||
|
|
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(
|
|
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
|
|
230
|
+
runPendingRestart(state);
|
|
172
231
|
|
|
173
232
|
return;
|
|
174
233
|
}
|
|
175
234
|
|
|
176
|
-
if (state
|
|
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,
|
|
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(() =>
|
|
268
|
+
timer.handle = setTimeout(() => {
|
|
269
|
+
timer.handle = null;
|
|
270
|
+
void restart(state);
|
|
271
|
+
}, CONFIG_DEBOUNCE_MS);
|
|
204
272
|
};
|
|
205
273
|
|
|
206
|
-
const
|
|
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
|
-
|
|
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 =
|
|
351
|
+
const { directory, name } = nearestWatchTarget(path);
|
|
219
352
|
const names = namesByDirectory.get(directory) ?? new Set<string>();
|
|
220
|
-
names.add(
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|