@termix-ssh/plugin-sdk 1.0.0 → 1.0.2
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/CHANGELOG.md +54 -0
- package/README.md +8 -1
- package/cli/commands/changelog.mjs +25 -0
- package/cli/commands/dev.mjs +158 -0
- package/cli/commands/validate.mjs +16 -24
- package/cli/index.mjs +8 -0
- package/cli/lib/tmxplug.mjs +1 -1
- package/dist/backend.d.ts +6 -2
- package/dist/changelog.d.ts +62 -0
- package/dist/changelog.js +187 -0
- package/dist/frontend.d.ts +67 -1
- package/dist/host/{chunk-F3SDZDDG.js → chunk-LKYAVM4T.js} +17 -2
- package/dist/host/{chunk-XH5NXL6Y.js → chunk-SS43S6VI.js} +7816 -7344
- package/dist/host/{chunk-XGWE7BTJ.js → chunk-XKMRKQFN.js} +6 -1
- package/dist/host/{linked-server-FZGWQW32.js → linked-server-DW5P3NLZ.js} +1 -1
- package/dist/host/{rbac-api-6KDMBVDZ.js → rbac-api-JKISXZEP.js} +1 -1
- package/dist/host/testing-host.js +38 -5
- package/dist/host/types/src/types/credential-sidebar-preferences.d.ts +12 -1
- package/dist/host/types/src/types/host-sidebar-preferences.d.ts +18 -1
- package/dist/host/types/src/types/onboarding.d.ts +29 -0
- package/dist/host/types/src/types/plugin-onboarding.d.ts +39 -0
- package/dist/host/types/src/types/ui-preferences.d.ts +13 -4
- package/dist/host/types/src/types/ui-types.d.ts +1 -1
- package/dist/host/types/src/ui/api/plugins-api.d.ts +211 -0
- package/dist/host/types/src/ui/api/system-status-api.d.ts +24 -0
- package/dist/host/types/src/ui/components/connection/ConnectionLogPanel.d.ts +2 -1
- package/dist/host/types/src/ui/components/connection/ConnectionScreen.d.ts +5 -5
- package/dist/host/types/src/ui/components/list-kit.d.ts +102 -0
- package/dist/host/types/src/ui/components/panel-layout.d.ts +6 -1
- package/dist/host/types/src/ui/components/surface/surface-scope.d.ts +5 -1
- package/dist/host/types/src/ui/contexts/UiPreferencesContext.d.ts +15 -2
- package/dist/host/types/src/ui/hooks/use-installed-plugins.d.ts +6 -0
- package/dist/host/types/src/ui/hooks/use-permissions.d.ts +2 -0
- package/dist/host/types/src/ui/lib/embedded-frame.d.ts +14 -0
- package/dist/host/types/src/ui/onboarding/onboarding-registry.d.ts +49 -0
- package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -0
- package/dist/host/types/src/ui/plugins/open-plugins.d.ts +5 -0
- package/dist/host/types/src/ui/settings/settings-fields-util.d.ts +10 -0
- package/dist/host/types/src/ui/sidebar/host-contributions.d.ts +2 -0
- package/dist/host/types/src/ui/sidebar/rail-items.d.ts +10 -0
- package/dist/host/ui.js +1097 -787
- package/dist/host/{use-permissions-NULZL43J.js → use-permissions-5CZEA3P4.js} +3 -1
- package/dist/manifest.d.ts +19 -2
- package/dist/manifest.js +82 -1
- package/dist/testing/setup.js +8 -0
- package/dist/testing.d.ts +12 -0
- package/dist/vitest-preset.d.ts +3 -0
- package/dist/vitest-preset.js +6 -0
- package/package.json +7 -3
- package/schema/manifest.schema.json +12 -1
- package/cli/lib/changelog.mjs +0 -126
- package/schema/changelog.schema.json +0 -67
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `features` manifest field: up to 20 short lines about what the plugin does, shown on its page. `pluginFeatures()` reads them
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `parseChangelog()` and `validateChangelog()` flag a `## Unreleased` section. Notes go under the version they ship in
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- `Changelog.unreleased`
|
|
16
|
+
|
|
17
|
+
## 1.0.1
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Plugin API 1.1
|
|
22
|
+
- `app.registerOnboardingStep()` adds a step to onboarding, with `OnboardingStepProps` and `OnboardingStepContribution`. Needs `engine.api` 1.1
|
|
23
|
+
- `video` manifest field: a YouTube link shown at the top of the plugin's page, read with `youtubeVideoId()`
|
|
24
|
+
- `@termix-ssh/plugin-sdk/changelog` parses and checks a plugin's `CHANGELOG.md`
|
|
25
|
+
- `termix-plugin changelog` prints one version's notes from `CHANGELOG.md`
|
|
26
|
+
- `termix-plugin dev` installs a plugin on a running server and reinstalls it on every change. Needs developer mode and an admin API key
|
|
27
|
+
- `renderOnboardingStep()` and `registered.onboardingSteps()` in `renderWithApp()`
|
|
28
|
+
- `AddButton`, `FormFooter`, `ListRow`, `ListRowAction`, `ListRowFolder`, `ListBadge` and `PanelList` in `@termix-ssh/plugin-sdk/ui`
|
|
29
|
+
- `PanelSearch` takes `disabled`, `title`, `onEnter` and `inputRef`, and `SurfaceScope` takes `onClose`
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Release notes moved from `CHANGELOG.json` to `CHANGELOG.md`, which `pack` now ships. `validate` flags a leftover `CHANGELOG.json`
|
|
34
|
+
- `ConnectionScreen` dropped `errorMessage` and `retryLabel` and gained `onClose`. `ConnectionLogPanel` dropped `position`
|
|
35
|
+
- Plugin tests run in worker threads, since forked workers crash now and then on Windows
|
|
36
|
+
- The test setup stubs `ResizeObserver` for Radix controls under jsdom
|
|
37
|
+
- `onboarding` is a reserved plugin id
|
|
38
|
+
|
|
39
|
+
### Deprecated
|
|
40
|
+
|
|
41
|
+
- The `onboarding.*` action slots. Use `registerOnboardingStep()`
|
|
42
|
+
|
|
43
|
+
### Removed
|
|
44
|
+
|
|
45
|
+
- `schema/changelog.schema.json`
|
|
46
|
+
|
|
47
|
+
## 1.0.0
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- Backend, frontend, database, manifest, capability and settings types for plugin API 1.0
|
|
52
|
+
- Testing helpers and a Vitest preset for plugin tests
|
|
53
|
+
- The `@termix-ssh/plugin-sdk/ui` component set
|
|
54
|
+
- The `termix-plugin` CLI to build, validate, test, pack, sign and write migrations
|
package/README.md
CHANGED
|
@@ -32,16 +32,23 @@ 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
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
|
45
52
|
|
|
46
53
|
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
|
47
54
|
|
|
@@ -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
|
|
12
|
-
const entry = new 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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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);
|
package/cli/lib/tmxplug.mjs
CHANGED
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
|
-
*
|
|
1605
|
-
* image.
|
|
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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
|
|
3
|
+
*
|
|
4
|
+
* # Changelog
|
|
5
|
+
*
|
|
6
|
+
* ## 1.1.0
|
|
7
|
+
*
|
|
8
|
+
* An optional summary paragraph.
|
|
9
|
+
*
|
|
10
|
+
* ### Added
|
|
11
|
+
* - Something new
|
|
12
|
+
*
|
|
13
|
+
* ### Fixed
|
|
14
|
+
* - Something broken
|
|
15
|
+
*
|
|
16
|
+
* Releases are listed newest first, and the newest one has to be the
|
|
17
|
+
* manifest version. Notes go straight under the version they ship in, there
|
|
18
|
+
* is no Unreleased section.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CHANGE_TYPES: readonly ["added", "changed", "fixed", "removed", "deprecated", "security"];
|
|
21
|
+
export type ChangeType = (typeof CHANGE_TYPES)[number];
|
|
22
|
+
export interface ChangelogChange {
|
|
23
|
+
type: ChangeType;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ChangelogRelease {
|
|
27
|
+
version: string;
|
|
28
|
+
date?: string;
|
|
29
|
+
summary?: string;
|
|
30
|
+
changes: ChangelogChange[];
|
|
31
|
+
}
|
|
32
|
+
export interface Changelog {
|
|
33
|
+
/** Newest first, as written. */
|
|
34
|
+
releases: ChangelogRelease[];
|
|
35
|
+
}
|
|
36
|
+
export interface ReleaseNotes {
|
|
37
|
+
summary?: string;
|
|
38
|
+
changes: ChangelogChange[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parses the body under one release heading: an optional summary, then
|
|
42
|
+
* `### Added` style sections of bullets. Indented lines continue the bullet
|
|
43
|
+
* above them.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseReleaseNotes(body: string, where?: string): ReleaseNotes & {
|
|
46
|
+
problems: string[];
|
|
47
|
+
};
|
|
48
|
+
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
|
|
49
|
+
export declare function parseChangelog(markdown: string): {
|
|
50
|
+
changelog: Changelog;
|
|
51
|
+
problems: string[];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
|
55
|
+
* be that version, so a release never ships without its notes.
|
|
56
|
+
*/
|
|
57
|
+
export declare function validateChangelog(markdown: string, manifestVersion?: string): string[];
|
|
58
|
+
/**
|
|
59
|
+
* The Markdown under one version's heading, as written, for a release body.
|
|
60
|
+
* Null when the version is not in the file.
|
|
61
|
+
*/
|
|
62
|
+
export declare function changelogSection(markdown: string, version: string): string | null;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
|
|
3
|
+
*
|
|
4
|
+
* # Changelog
|
|
5
|
+
*
|
|
6
|
+
* ## 1.1.0
|
|
7
|
+
*
|
|
8
|
+
* An optional summary paragraph.
|
|
9
|
+
*
|
|
10
|
+
* ### Added
|
|
11
|
+
* - Something new
|
|
12
|
+
*
|
|
13
|
+
* ### Fixed
|
|
14
|
+
* - Something broken
|
|
15
|
+
*
|
|
16
|
+
* Releases are listed newest first, and the newest one has to be the
|
|
17
|
+
* manifest version. Notes go straight under the version they ship in, there
|
|
18
|
+
* is no Unreleased section.
|
|
19
|
+
*/
|
|
20
|
+
import semver from "semver";
|
|
21
|
+
export const CHANGE_TYPES = [
|
|
22
|
+
"added",
|
|
23
|
+
"changed",
|
|
24
|
+
"fixed",
|
|
25
|
+
"removed",
|
|
26
|
+
"deprecated",
|
|
27
|
+
"security",
|
|
28
|
+
];
|
|
29
|
+
const RELEASE_HEADING = /^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
|
|
30
|
+
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
|
|
31
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
|
+
const BULLET = /^[-*]\s+(.*)$/;
|
|
33
|
+
const COMMENT = /^<!--.*-->$/;
|
|
34
|
+
/**
|
|
35
|
+
* Parses the body under one release heading: an optional summary, then
|
|
36
|
+
* `### Added` style sections of bullets. Indented lines continue the bullet
|
|
37
|
+
* above them.
|
|
38
|
+
*/
|
|
39
|
+
export function parseReleaseNotes(body, where = "release") {
|
|
40
|
+
const problems = [];
|
|
41
|
+
const summary = [];
|
|
42
|
+
const changes = [];
|
|
43
|
+
let section = null;
|
|
44
|
+
let current = null;
|
|
45
|
+
let fence = false;
|
|
46
|
+
for (const raw of body.replace(/\r/g, "").split("\n")) {
|
|
47
|
+
const line = raw.trimEnd();
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (trimmed.startsWith("```"))
|
|
50
|
+
fence = !fence;
|
|
51
|
+
if (!fence && trimmed.startsWith("### ")) {
|
|
52
|
+
const name = trimmed.slice(4).trim().toLowerCase();
|
|
53
|
+
if (CHANGE_TYPES.includes(name)) {
|
|
54
|
+
section = name;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
problems.push(`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`);
|
|
58
|
+
section = null;
|
|
59
|
+
}
|
|
60
|
+
current = null;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!section) {
|
|
64
|
+
if (trimmed || summary.length > 0)
|
|
65
|
+
summary.push(line);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!trimmed || COMMENT.test(trimmed))
|
|
69
|
+
continue;
|
|
70
|
+
const bullet = fence ? null : BULLET.exec(line);
|
|
71
|
+
if (bullet) {
|
|
72
|
+
current = { type: section, text: bullet[1].trim() };
|
|
73
|
+
changes.push(current);
|
|
74
|
+
}
|
|
75
|
+
else if (current && /^\s/.test(line)) {
|
|
76
|
+
current.text += `\n${trimmed}`;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
problems.push(`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const change of changes) {
|
|
83
|
+
if (!change.text) {
|
|
84
|
+
problems.push(`${where} has an empty list item`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const text = summary.join("\n").trim();
|
|
88
|
+
return { ...(text ? { summary: text } : {}), changes, problems };
|
|
89
|
+
}
|
|
90
|
+
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
|
|
91
|
+
export function parseChangelog(markdown) {
|
|
92
|
+
const problems = [];
|
|
93
|
+
const changelog = { releases: [] };
|
|
94
|
+
const lines = markdown.replace(/\r/g, "").split("\n");
|
|
95
|
+
const blocks = [];
|
|
96
|
+
let fence = false;
|
|
97
|
+
lines.forEach((line, i) => {
|
|
98
|
+
if (line.trim().startsWith("```"))
|
|
99
|
+
fence = !fence;
|
|
100
|
+
if (!fence && /^##\s/.test(line)) {
|
|
101
|
+
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
|
|
102
|
+
}
|
|
103
|
+
else if (blocks.length > 0) {
|
|
104
|
+
blocks[blocks.length - 1].body.push(line);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
blocks.forEach((block) => {
|
|
109
|
+
if (UNRELEASED_HEADING.test(block.heading)) {
|
|
110
|
+
problems.push(`line ${block.line}: drop "## Unreleased", notes go under the version they ship in`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const match = RELEASE_HEADING.exec(block.heading);
|
|
114
|
+
if (!match) {
|
|
115
|
+
problems.push(`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const [, version, date] = match;
|
|
119
|
+
if (date !== undefined && !DATE.test(date)) {
|
|
120
|
+
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
|
|
121
|
+
}
|
|
122
|
+
if (seen.has(version))
|
|
123
|
+
problems.push(`${version} is listed twice`);
|
|
124
|
+
seen.add(version);
|
|
125
|
+
const notes = parseReleaseNotes(block.body.join("\n"), version);
|
|
126
|
+
problems.push(...notes.problems);
|
|
127
|
+
if (notes.changes.length === 0) {
|
|
128
|
+
problems.push(`${version} has no changes listed`);
|
|
129
|
+
}
|
|
130
|
+
changelog.releases.push({
|
|
131
|
+
version,
|
|
132
|
+
...(date && DATE.test(date) ? { date } : {}),
|
|
133
|
+
...(notes.summary ? { summary: notes.summary } : {}),
|
|
134
|
+
changes: notes.changes,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
const versions = changelog.releases.map((r) => r.version);
|
|
138
|
+
for (let i = 1; i < versions.length; i++) {
|
|
139
|
+
if (semver.lt(versions[i - 1], versions[i])) {
|
|
140
|
+
problems.push("releases must be listed newest first");
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { changelog, problems };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
|
148
|
+
* be that version, so a release never ships without its notes.
|
|
149
|
+
*/
|
|
150
|
+
export function validateChangelog(markdown, manifestVersion) {
|
|
151
|
+
const { changelog, problems } = parseChangelog(markdown);
|
|
152
|
+
if (changelog.releases.length === 0) {
|
|
153
|
+
problems.push('no releases found, add a "## 1.0.0" heading');
|
|
154
|
+
return problems;
|
|
155
|
+
}
|
|
156
|
+
const newest = changelog.releases[0].version;
|
|
157
|
+
if (manifestVersion && newest !== manifestVersion) {
|
|
158
|
+
problems.push(`the newest release is ${newest}, but the manifest version is ${manifestVersion}`);
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The Markdown under one version's heading, as written, for a release body.
|
|
164
|
+
* Null when the version is not in the file.
|
|
165
|
+
*/
|
|
166
|
+
export function changelogSection(markdown, version) {
|
|
167
|
+
const lines = markdown.replace(/\r/g, "").split("\n");
|
|
168
|
+
let start = -1;
|
|
169
|
+
let fence = false;
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
if (line.trim().startsWith("```"))
|
|
173
|
+
fence = !fence;
|
|
174
|
+
if (fence || !/^##\s/.test(line))
|
|
175
|
+
continue;
|
|
176
|
+
if (start >= 0) {
|
|
177
|
+
return lines.slice(start, i).join("\n").trim();
|
|
178
|
+
}
|
|
179
|
+
const match = RELEASE_HEADING.exec(line.trim());
|
|
180
|
+
if (match && match[1] === version)
|
|
181
|
+
start = i + 1;
|
|
182
|
+
}
|
|
183
|
+
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
|
184
|
+
}
|
|
185
|
+
function title(type) {
|
|
186
|
+
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
187
|
+
}
|