@termix-ssh/plugin-sdk 1.0.0 → 1.0.1

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 (48) hide show
  1. package/README.md +7 -0
  2. package/cli/commands/changelog.mjs +25 -0
  3. package/cli/commands/dev.mjs +158 -0
  4. package/cli/commands/validate.mjs +16 -24
  5. package/cli/index.mjs +8 -0
  6. package/cli/lib/tmxplug.mjs +1 -1
  7. package/dist/backend.d.ts +6 -2
  8. package/dist/changelog.d.ts +67 -0
  9. package/dist/changelog.js +195 -0
  10. package/dist/frontend.d.ts +67 -1
  11. package/dist/host/{chunk-XGWE7BTJ.js → chunk-3KG44G55.js} +6 -1
  12. package/dist/host/{chunk-XH5NXL6Y.js → chunk-7GL25GFS.js} +8013 -7606
  13. package/dist/host/{chunk-F3SDZDDG.js → chunk-COGRSHKO.js} +17 -2
  14. package/dist/host/{linked-server-FZGWQW32.js → linked-server-BD573BEG.js} +1 -1
  15. package/dist/host/{rbac-api-6KDMBVDZ.js → rbac-api-7GHDJLU4.js} +1 -1
  16. package/dist/host/testing-host.js +38 -5
  17. package/dist/host/types/src/types/credential-sidebar-preferences.d.ts +12 -1
  18. package/dist/host/types/src/types/host-sidebar-preferences.d.ts +18 -1
  19. package/dist/host/types/src/types/onboarding.d.ts +29 -0
  20. package/dist/host/types/src/types/plugin-onboarding.d.ts +39 -0
  21. package/dist/host/types/src/types/ui-preferences.d.ts +13 -4
  22. package/dist/host/types/src/types/ui-types.d.ts +1 -1
  23. package/dist/host/types/src/ui/api/plugins-api.d.ts +194 -0
  24. package/dist/host/types/src/ui/components/connection/ConnectionLogPanel.d.ts +2 -1
  25. package/dist/host/types/src/ui/components/connection/ConnectionScreen.d.ts +5 -5
  26. package/dist/host/types/src/ui/components/list-kit.d.ts +102 -0
  27. package/dist/host/types/src/ui/components/panel-layout.d.ts +6 -1
  28. package/dist/host/types/src/ui/components/surface/surface-scope.d.ts +5 -1
  29. package/dist/host/types/src/ui/contexts/UiPreferencesContext.d.ts +15 -2
  30. package/dist/host/types/src/ui/hooks/use-permissions.d.ts +2 -0
  31. package/dist/host/types/src/ui/lib/embedded-frame.d.ts +14 -0
  32. package/dist/host/types/src/ui/onboarding/onboarding-registry.d.ts +49 -0
  33. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -0
  34. package/dist/host/types/src/ui/plugins/open-plugins.d.ts +5 -0
  35. package/dist/host/types/src/ui/sidebar/host-contributions.d.ts +2 -0
  36. package/dist/host/types/src/ui/sidebar/rail-items.d.ts +10 -0
  37. package/dist/host/ui.js +982 -698
  38. package/dist/host/{use-permissions-NULZL43J.js → use-permissions-SKWXHWDJ.js} +3 -1
  39. package/dist/manifest.d.ts +10 -2
  40. package/dist/manifest.js +46 -1
  41. package/dist/testing/setup.js +8 -0
  42. package/dist/testing.d.ts +12 -0
  43. package/dist/vitest-preset.d.ts +3 -0
  44. package/dist/vitest-preset.js +6 -0
  45. package/package.json +5 -2
  46. package/schema/manifest.schema.json +6 -1
  47. package/cli/lib/changelog.mjs +0 -126
  48. package/schema/changelog.schema.json +0 -67
package/README.md CHANGED
@@ -32,11 +32,18 @@ npx termix-plugin test # run the plugin's Vitest suite
32
32
  npx termix-plugin validate # check manifest.json and the files it names
33
33
  npx termix-plugin migrations # write migrations from the table definitions
34
34
  npx termix-plugin pack # write <id>-<version>.tmxplug
35
+ npx termix-plugin changelog # print this version's notes from CHANGELOG.md
35
36
  npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
36
37
  npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
37
38
  npx termix-plugin keygen --out <dir> # make a new signing key pair
39
+ npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
38
40
  ```
39
41
 
42
+ `dev` needs developer mode on (Admin settings, Plugins) and an API key from an
43
+ admin account. It installs the plugin from a file, marked unverified, and
44
+ reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
45
+ in place of the flags. Reload the page to pick up frontend changes.
46
+
40
47
  <br />
41
48
 
42
49
  ## Support
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readManifest } from "../lib/plugin-dir.mjs";
4
+ import { loadSdkModule } from "./validate.mjs";
5
+
6
+ /**
7
+ * Prints one version's notes from CHANGELOG.md, the manifest version unless
8
+ * --version says otherwise. The release workflow uses it as the release body.
9
+ */
10
+ export async function changelog({ cwd, args = [] }) {
11
+ const index = args.indexOf("--version");
12
+ const version =
13
+ index === -1 ? readManifest(cwd).version : args[index + 1] || null;
14
+ if (!version) throw new Error("--version needs a value");
15
+
16
+ const file = path.join(cwd, "CHANGELOG.md");
17
+ if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md");
18
+
19
+ const { changelogSection } = await loadSdkModule("changelog");
20
+ const section = changelogSection(fs.readFileSync(file, "utf8"), version);
21
+ if (!section) {
22
+ throw new Error(`CHANGELOG.md has no notes for ${version}`);
23
+ }
24
+ process.stdout.write(`${section}\n`);
25
+ }
@@ -0,0 +1,158 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { build } from "./build.mjs";
6
+ import { pack } from "./pack.mjs";
7
+ import { readManifest } from "../lib/plugin-dir.mjs";
8
+
9
+ const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
10
+ const SOURCE_FILES = new Set(["manifest.json", "package.json", "README.md"]);
11
+
12
+ function readOption(args, name) {
13
+ const index = args.indexOf(name);
14
+ if (index === -1) return null;
15
+ const value = args[index + 1];
16
+ if (!value) throw new Error(`${name} needs a value`);
17
+ return value;
18
+ }
19
+
20
+ /** True when a changed path (relative to the plugin) feeds its build. */
21
+ export function isSourceChange(filename) {
22
+ if (!filename) return false;
23
+ const parts = String(filename).replaceAll("\\", "/").split("/");
24
+ if (parts.length === 1) return SOURCE_FILES.has(parts[0]);
25
+ return SOURCE_DIRS.has(parts[0]);
26
+ }
27
+
28
+ export function resolveDevOptions(args, env = process.env) {
29
+ const server = (
30
+ readOption(args, "--server") ??
31
+ env.TERMIX_SERVER_URL ??
32
+ "http://localhost:30001"
33
+ ).replace(/\/+$/, "");
34
+ const key = readOption(args, "--key") ?? env.TERMIX_API_KEY ?? null;
35
+ if (!key) {
36
+ throw new Error(
37
+ "An admin API key is needed. Pass --key tmx_... or set TERMIX_API_KEY.",
38
+ );
39
+ }
40
+ return { server, key, once: args.includes("--once") };
41
+ }
42
+
43
+ const HINTS = {
44
+ DEVELOPER_MODE_OFF:
45
+ "Turn on developer mode in Admin settings, under Plugins, then save again.",
46
+ SIGNED_ONLY:
47
+ "The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
48
+ BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
49
+ ALREADY_INSTALLED:
50
+ "Uninstall the copy installed from the registry first, then save again.",
51
+ };
52
+
53
+ async function request(fetchImpl, url, init) {
54
+ const response = await fetchImpl(url, init);
55
+ const text = await response.text();
56
+ let body = null;
57
+ try {
58
+ body = text ? JSON.parse(text) : null;
59
+ } catch {
60
+ body = { error: text };
61
+ }
62
+ if (!response.ok) {
63
+ const code = body?.code;
64
+ const hint = code && HINTS[code] ? `\n ${HINTS[code]}` : "";
65
+ throw new Error(
66
+ `${response.status} ${body?.error ?? response.statusText}${hint}`,
67
+ );
68
+ }
69
+ return body;
70
+ }
71
+
72
+ /** Uploads a packed .tmxplug and installs it, agreeing to what it asks for. */
73
+ export async function pushPlugin(
74
+ file,
75
+ { server, key },
76
+ fetchImpl = globalThis.fetch,
77
+ ) {
78
+ const auth = { Authorization: `Bearer ${key}` };
79
+ const preview = await request(fetchImpl, `${server}/plugins/upload`, {
80
+ method: "POST",
81
+ headers: { ...auth, "Content-Type": "application/octet-stream" },
82
+ body: fs.readFileSync(file),
83
+ });
84
+ const result = await request(
85
+ fetchImpl,
86
+ `${server}/plugins/upload/${preview.token}/install`,
87
+ {
88
+ method: "POST",
89
+ headers: { ...auth, "Content-Type": "application/json" },
90
+ body: JSON.stringify({ capabilities: preview.capabilities }),
91
+ },
92
+ );
93
+ return { preview, result };
94
+ }
95
+
96
+ /**
97
+ * Builds, packs and installs the plugin on a running Termix server, then
98
+ * does it again on every source change. The server needs developer mode on.
99
+ */
100
+ export async function dev({ cwd, args = [] }) {
101
+ const options = resolveDevOptions(args);
102
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-dev-"));
103
+ let lastCapabilities = null;
104
+
105
+ const cycle = async () => {
106
+ const started = Date.now();
107
+ await build({ cwd });
108
+ const file = await pack({ cwd, args: ["--out", outDir] });
109
+ const { preview, result } = await pushPlugin(file, options);
110
+ fs.rmSync(file, { force: true });
111
+ const capabilities = preview.capabilities.join(", ") || "none";
112
+ if (capabilities !== lastCapabilities) {
113
+ console.log(` capabilities ${capabilities}`);
114
+ lastCapabilities = capabilities;
115
+ }
116
+ console.log(
117
+ `installed ${result.id}@${result.version} on ${options.server} (${result.state}, ${Date.now() - started} ms)`,
118
+ );
119
+ };
120
+
121
+ try {
122
+ await cycle();
123
+ } catch (error) {
124
+ if (options.once) throw error;
125
+ console.error(error?.message ?? error);
126
+ }
127
+ if (options.once) return;
128
+
129
+ const { id } = readManifest(cwd);
130
+ console.log(`watching ${id} for changes, Ctrl+C to stop`);
131
+ let timer = null;
132
+ let running = false;
133
+ let again = false;
134
+ const fire = async () => {
135
+ if (running) {
136
+ again = true;
137
+ return;
138
+ }
139
+ running = true;
140
+ try {
141
+ await cycle();
142
+ } catch (error) {
143
+ console.error(error?.message ?? error);
144
+ } finally {
145
+ running = false;
146
+ if (again) {
147
+ again = false;
148
+ void fire();
149
+ }
150
+ }
151
+ };
152
+ fs.watch(cwd, { recursive: true }, (_event, filename) => {
153
+ if (!isSourceChange(filename)) return;
154
+ clearTimeout(timer);
155
+ timer = setTimeout(() => void fire(), 300);
156
+ });
157
+ await new Promise(() => {});
158
+ }
@@ -2,24 +2,22 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readManifest } from "../lib/plugin-dir.mjs";
5
- import { validateChangelog } from "../lib/changelog.mjs";
6
5
 
7
6
  /**
8
7
  * The manifest rules live in src/manifest.ts, which the server uses too, so
9
8
  * there is one implementation rather than a copy here that drifts.
10
9
  */
11
- async function loadParseManifest() {
12
- const entry = new URL("../../dist/manifest.js", import.meta.url);
10
+ export async function loadSdkModule(name) {
11
+ const entry = new URL(`../../dist/${name}.js`, import.meta.url);
13
12
  if (!fs.existsSync(fileURLToPath(entry))) {
14
13
  throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
15
14
  }
16
- const mod = await import(entry.href);
17
- return mod.parseManifest;
15
+ return import(entry.href);
18
16
  }
19
17
 
20
18
  export async function validate({ cwd }) {
21
19
  const raw = readManifest(cwd);
22
- const parseManifest = await loadParseManifest();
20
+ const { parseManifest } = await loadSdkModule("manifest");
23
21
  const { errors } = parseManifest(raw);
24
22
 
25
23
  const problems = [...errors];
@@ -41,7 +39,7 @@ export async function validate({ cwd }) {
41
39
  );
42
40
  problems.push(...validateNativeDependencies(cwd, raw));
43
41
  problems.push(...validatePackage(cwd, raw));
44
- problems.push(...validateChangelogFile(cwd, raw));
42
+ problems.push(...(await validateChangelogFile(cwd, raw)));
45
43
 
46
44
  if (problems.length > 0) {
47
45
  for (const problem of problems) console.error(` ${problem}`);
@@ -82,26 +80,20 @@ function validatePackage(cwd, raw) {
82
80
  }
83
81
 
84
82
  /**
85
- * CHANGELOG.json is optional, but when it is there it has to parse and its
83
+ * CHANGELOG.md is optional, but when it is there it has to parse and its
86
84
  * newest release has to match the manifest version.
87
85
  */
88
- function validateChangelogFile(cwd, raw) {
89
- const jsonPath = path.join(cwd, "CHANGELOG.json");
90
- if (!fs.existsSync(jsonPath)) {
91
- return fs.existsSync(path.join(cwd, "CHANGELOG.md"))
92
- ? [
93
- "CHANGELOG.md is no longer shipped; move the release notes to CHANGELOG.json",
94
- ]
95
- : [];
86
+ async function validateChangelogFile(cwd, raw) {
87
+ if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
88
+ return [
89
+ "CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
90
+ ];
96
91
  }
97
- let changelog;
98
- try {
99
- changelog = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
100
- } catch (error) {
101
- return [`CHANGELOG.json is not valid JSON: ${error.message}`];
102
- }
103
- return validateChangelog(changelog, raw.version).map(
104
- (problem) => `CHANGELOG.json: ${problem}`,
92
+ const mdPath = path.join(cwd, "CHANGELOG.md");
93
+ if (!fs.existsSync(mdPath)) return [];
94
+ const { validateChangelog } = await loadSdkModule("changelog");
95
+ return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
96
+ (problem) => `CHANGELOG.md: ${problem}`,
105
97
  );
106
98
  }
107
99
 
package/cli/index.mjs CHANGED
@@ -13,6 +13,8 @@ import { test } from "./commands/test.mjs";
13
13
  import { pack } from "./commands/pack.mjs";
14
14
  import { sign, verify, keygen } from "./commands/sign.mjs";
15
15
  import { migrations } from "./commands/migrations.mjs";
16
+ import { dev } from "./commands/dev.mjs";
17
+ import { changelog } from "./commands/changelog.mjs";
16
18
 
17
19
  const COMMANDS = {
18
20
  build,
@@ -28,6 +30,8 @@ const COMMANDS = {
28
30
  verify,
29
31
  keygen,
30
32
  migrations,
33
+ dev,
34
+ changelog,
31
35
  };
32
36
 
33
37
  const [command, ...args] = process.argv.slice(2);
@@ -46,6 +50,10 @@ if (!command || command === "--help" || command === "-h") {
46
50
  " verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
47
51
  " keygen Write a new signing key pair [--out dir]",
48
52
  " migrations Generate migrations from the plugin's table definitions",
53
+ " changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
54
+ " dev Build, pack and install on a running server, again on every change",
55
+ " [--server url] [--key tmx_...] [--once]",
56
+ " (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
49
57
  ].join("\n"),
50
58
  );
51
59
  process.exit(command ? 0 : 1);
@@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [
9
9
  "locales",
10
10
  "migrations",
11
11
  "README.md",
12
- "CHANGELOG.json",
12
+ "CHANGELOG.md",
13
13
  "icon.svg",
14
14
  ];
15
15
 
package/dist/backend.d.ts CHANGED
@@ -285,6 +285,9 @@ export interface PluginRouterOptions {
285
285
  * and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
286
286
  * ":name" segment matches one segment, and a trailing "/*" matches any
287
287
  * number of further segments, for a proxied page ("/chooser/:id/*").
288
+ * An entry only opens the route registered with that same path, for the
289
+ * methods it was registered with, so a literal route beside a ":param"
290
+ * ("/webhook/config" next to "/webhook/:token") still needs a login.
288
291
  *
289
292
  * For the handful of routes an unauthenticated third party has to reach: an
290
293
  * OIDC callback, an inbound webhook. Every entry is audited when the router
@@ -1601,8 +1604,9 @@ export interface PluginBinarySpec {
1601
1604
  /** Hex SHA-256 the file must have. */
1602
1605
  sha256: string;
1603
1606
  /**
1604
- * Paths checked before downloading, such as a copy baked into the Docker
1605
- * image. One whose checksum matches is used in place.
1607
+ * Absolute paths checked before downloading, such as a copy baked into the
1608
+ * Docker image. Only a regular file named `name` is considered, and one
1609
+ * whose checksum matches is used in place.
1606
1610
  */
1607
1611
  prebuilt?: readonly string[];
1608
1612
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
3
+ *
4
+ * # Changelog
5
+ *
6
+ * ## Unreleased
7
+ *
8
+ * ### Added
9
+ * - Something merged but not shipped yet
10
+ *
11
+ * ## 1.1.0 - 2026-10-06
12
+ *
13
+ * An optional summary paragraph.
14
+ *
15
+ * ### Added
16
+ * - Something new
17
+ *
18
+ * ### Fixed
19
+ * - Something broken
20
+ *
21
+ * Releases are listed newest first, and the newest one has to be the
22
+ * manifest version. The date is optional until the version ships.
23
+ */
24
+ export declare const CHANGE_TYPES: readonly ["added", "changed", "fixed", "removed", "deprecated", "security"];
25
+ export type ChangeType = (typeof CHANGE_TYPES)[number];
26
+ export interface ChangelogChange {
27
+ type: ChangeType;
28
+ text: string;
29
+ }
30
+ export interface ChangelogRelease {
31
+ version: string;
32
+ date?: string;
33
+ summary?: string;
34
+ changes: ChangelogChange[];
35
+ }
36
+ export interface Changelog {
37
+ unreleased: ChangelogChange[];
38
+ /** Newest first, as written. */
39
+ releases: ChangelogRelease[];
40
+ }
41
+ export interface ReleaseNotes {
42
+ summary?: string;
43
+ changes: ChangelogChange[];
44
+ }
45
+ /**
46
+ * Parses the body under one release heading: an optional summary, then
47
+ * `### Added` style sections of bullets. Indented lines continue the bullet
48
+ * above them.
49
+ */
50
+ export declare function parseReleaseNotes(body: string, where?: string): ReleaseNotes & {
51
+ problems: string[];
52
+ };
53
+ /** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
54
+ export declare function parseChangelog(markdown: string): {
55
+ changelog: Changelog;
56
+ problems: string[];
57
+ };
58
+ /**
59
+ * Checks a CHANGELOG.md. With a manifest version, the newest release has to
60
+ * be that version, so a release never ships without its notes.
61
+ */
62
+ export declare function validateChangelog(markdown: string, manifestVersion?: string): string[];
63
+ /**
64
+ * The Markdown under one version's heading, as written, for a release body.
65
+ * Null when the version is not in the file.
66
+ */
67
+ export declare function changelogSection(markdown: string, version: string): string | null;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
3
+ *
4
+ * # Changelog
5
+ *
6
+ * ## Unreleased
7
+ *
8
+ * ### Added
9
+ * - Something merged but not shipped yet
10
+ *
11
+ * ## 1.1.0 - 2026-10-06
12
+ *
13
+ * An optional summary paragraph.
14
+ *
15
+ * ### Added
16
+ * - Something new
17
+ *
18
+ * ### Fixed
19
+ * - Something broken
20
+ *
21
+ * Releases are listed newest first, and the newest one has to be the
22
+ * manifest version. The date is optional until the version ships.
23
+ */
24
+ import semver from "semver";
25
+ export const CHANGE_TYPES = [
26
+ "added",
27
+ "changed",
28
+ "fixed",
29
+ "removed",
30
+ "deprecated",
31
+ "security",
32
+ ];
33
+ const RELEASE_HEADING = /^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
34
+ const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
35
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
36
+ const BULLET = /^[-*]\s+(.*)$/;
37
+ const COMMENT = /^<!--.*-->$/;
38
+ /**
39
+ * Parses the body under one release heading: an optional summary, then
40
+ * `### Added` style sections of bullets. Indented lines continue the bullet
41
+ * above them.
42
+ */
43
+ export function parseReleaseNotes(body, where = "release") {
44
+ const problems = [];
45
+ const summary = [];
46
+ const changes = [];
47
+ let section = null;
48
+ let current = null;
49
+ let fence = false;
50
+ for (const raw of body.replace(/\r/g, "").split("\n")) {
51
+ const line = raw.trimEnd();
52
+ const trimmed = line.trim();
53
+ if (trimmed.startsWith("```"))
54
+ fence = !fence;
55
+ if (!fence && trimmed.startsWith("### ")) {
56
+ const name = trimmed.slice(4).trim().toLowerCase();
57
+ if (CHANGE_TYPES.includes(name)) {
58
+ section = name;
59
+ }
60
+ else {
61
+ problems.push(`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`);
62
+ section = null;
63
+ }
64
+ current = null;
65
+ continue;
66
+ }
67
+ if (!section) {
68
+ if (trimmed || summary.length > 0)
69
+ summary.push(line);
70
+ continue;
71
+ }
72
+ if (!trimmed || COMMENT.test(trimmed))
73
+ continue;
74
+ const bullet = fence ? null : BULLET.exec(line);
75
+ if (bullet) {
76
+ current = { type: section, text: bullet[1].trim() };
77
+ changes.push(current);
78
+ }
79
+ else if (current && /^\s/.test(line)) {
80
+ current.text += `\n${trimmed}`;
81
+ }
82
+ else {
83
+ problems.push(`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`);
84
+ }
85
+ }
86
+ for (const change of changes) {
87
+ if (!change.text) {
88
+ problems.push(`${where} has an empty list item`);
89
+ }
90
+ }
91
+ const text = summary.join("\n").trim();
92
+ return { ...(text ? { summary: text } : {}), changes, problems };
93
+ }
94
+ /** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
95
+ export function parseChangelog(markdown) {
96
+ const problems = [];
97
+ const changelog = { unreleased: [], releases: [] };
98
+ const lines = markdown.replace(/\r/g, "").split("\n");
99
+ const blocks = [];
100
+ let fence = false;
101
+ lines.forEach((line, i) => {
102
+ if (line.trim().startsWith("```"))
103
+ fence = !fence;
104
+ if (!fence && /^##\s/.test(line)) {
105
+ blocks.push({ heading: line.trim(), line: i + 1, body: [] });
106
+ }
107
+ else if (blocks.length > 0) {
108
+ blocks[blocks.length - 1].body.push(line);
109
+ }
110
+ });
111
+ const seen = new Set();
112
+ blocks.forEach((block, i) => {
113
+ if (UNRELEASED_HEADING.test(block.heading)) {
114
+ if (i !== 0)
115
+ problems.push(`"## Unreleased" has to come first`);
116
+ const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased");
117
+ problems.push(...notes.problems);
118
+ changelog.unreleased = notes.changes;
119
+ return;
120
+ }
121
+ const match = RELEASE_HEADING.exec(block.heading);
122
+ if (!match) {
123
+ problems.push(`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`);
124
+ return;
125
+ }
126
+ const [, version, date] = match;
127
+ if (date !== undefined && !DATE.test(date)) {
128
+ problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
129
+ }
130
+ if (seen.has(version))
131
+ problems.push(`${version} is listed twice`);
132
+ seen.add(version);
133
+ const notes = parseReleaseNotes(block.body.join("\n"), version);
134
+ problems.push(...notes.problems);
135
+ if (notes.changes.length === 0) {
136
+ problems.push(`${version} has no changes listed`);
137
+ }
138
+ changelog.releases.push({
139
+ version,
140
+ ...(date && DATE.test(date) ? { date } : {}),
141
+ ...(notes.summary ? { summary: notes.summary } : {}),
142
+ changes: notes.changes,
143
+ });
144
+ });
145
+ const versions = changelog.releases.map((r) => r.version);
146
+ for (let i = 1; i < versions.length; i++) {
147
+ if (semver.lt(versions[i - 1], versions[i])) {
148
+ problems.push("releases must be listed newest first");
149
+ break;
150
+ }
151
+ }
152
+ return { changelog, problems };
153
+ }
154
+ /**
155
+ * Checks a CHANGELOG.md. With a manifest version, the newest release has to
156
+ * be that version, so a release never ships without its notes.
157
+ */
158
+ export function validateChangelog(markdown, manifestVersion) {
159
+ const { changelog, problems } = parseChangelog(markdown);
160
+ if (changelog.releases.length === 0) {
161
+ problems.push('no releases found, add a "## 1.0.0" heading');
162
+ return problems;
163
+ }
164
+ const newest = changelog.releases[0].version;
165
+ if (manifestVersion && newest !== manifestVersion) {
166
+ problems.push(`the newest release is ${newest}, but the manifest version is ${manifestVersion}`);
167
+ }
168
+ return problems;
169
+ }
170
+ /**
171
+ * The Markdown under one version's heading, as written, for a release body.
172
+ * Null when the version is not in the file.
173
+ */
174
+ export function changelogSection(markdown, version) {
175
+ const lines = markdown.replace(/\r/g, "").split("\n");
176
+ let start = -1;
177
+ let fence = false;
178
+ for (let i = 0; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ if (line.trim().startsWith("```"))
181
+ fence = !fence;
182
+ if (fence || !/^##\s/.test(line))
183
+ continue;
184
+ if (start >= 0) {
185
+ return lines.slice(start, i).join("\n").trim();
186
+ }
187
+ const match = RELEASE_HEADING.exec(line.trim());
188
+ if (match && match[1] === version)
189
+ start = i + 1;
190
+ }
191
+ return start >= 0 ? lines.slice(start).join("\n").trim() : null;
192
+ }
193
+ function title(type) {
194
+ return type.charAt(0).toUpperCase() + type.slice(1);
195
+ }