deck-plugin-sdk 1.12.3 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/deck-plugin.mjs +2 -1
- package/package.json +1 -1
- package/publish.mjs +19 -3
- package/template/CHANGELOG.md +5 -0
- package/template/plugin.json +1 -1
- package/types/core/bridge.d.ts +2 -1
- package/types/core/editor/conflictlens.d.ts +3 -0
- package/types/core/editor/conflicts.d.ts +23 -0
- package/validate.mjs +118 -4
package/README.md
CHANGED
|
@@ -94,4 +94,6 @@ needs a newer one, with a sentence rather than an error from inside your module.
|
|
|
94
94
|
## Publishing
|
|
95
95
|
|
|
96
96
|
A plugin is published by being listed in Deck's registry, which points at a release in your own
|
|
97
|
-
repository.
|
|
97
|
+
repository. `deck-plugin publish` makes the release and submits it; `deck-plugin publish --dry-run`
|
|
98
|
+
runs every check the registry will and prints the points the version will get, check by check.
|
|
99
|
+
Everything else is in [Publishing](../docs/guide/publishing.md).
|
package/bin/deck-plugin.mjs
CHANGED
|
@@ -82,7 +82,8 @@ function copyInto(manifest, defaultTarget) {
|
|
|
82
82
|
const { target, dir: plugins } = pluginsDir(defaultTarget);
|
|
83
83
|
const dest = join(plugins, manifest.id);
|
|
84
84
|
mkdirSync(dest, { recursive: true });
|
|
85
|
-
|
|
85
|
+
// icon.svg only when plugin.json names it: Deck reads it from beside the manifest.
|
|
86
|
+
for (const f of ["plugin.json", "plugin.js", "mcp.js", ...(manifest.icon === "icon.svg" ? ["icon.svg"] : [])]) {
|
|
86
87
|
if (existsSync(join(dir, f))) cpSync(join(dir, f), join(dest, f));
|
|
87
88
|
}
|
|
88
89
|
return { target, dest };
|
package/package.json
CHANGED
package/publish.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// the manifest, README, LICENSE and package-lock.json; a clean, pushed git tree; a build from
|
|
9
9
|
// the lockfile exactly as the registry will repeat it (npm ci, then this SDK's build); the
|
|
10
10
|
// release's files and sizes; and that its permissions cover what the bundle calls.
|
|
11
|
+
// It also prints the points the registry will give this version (validate.mjs, points()).
|
|
11
12
|
// Then it tags v<version>, makes the GitHub release with the built files, and opens a pull
|
|
12
13
|
// request to the registry adding submissions/<id>.json, through the author's fork. The registry's
|
|
13
14
|
// checks run on that PR and merge it when they pass.
|
|
@@ -17,7 +18,7 @@ import { execFileSync } from "node:child_process";
|
|
|
17
18
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { checkAssets, checkId, checkLockfile, checkManifest, checkPermissions } from "./validate.mjs";
|
|
21
|
+
import { checkAssets, checkIcon, checkId, checkLockfile, checkManifest, checkPermissions, fileFacts, ICON_FILE, points } from "./validate.mjs";
|
|
21
22
|
|
|
22
23
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const REGISTRY = "deck-desktop/deck-registry";
|
|
@@ -38,6 +39,10 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
|
|
|
38
39
|
// ---- what is in the folder ----
|
|
39
40
|
errors.push(...checkId(manifest.id ?? ""), ...checkManifest(manifest));
|
|
40
41
|
if (!existsSync(join(dir, "README.md"))) errors.push("no README.md: it is the plugin's page in Deck and on the website");
|
|
42
|
+
if (manifest.icon === ICON_FILE) {
|
|
43
|
+
if (!existsSync(join(dir, ICON_FILE))) errors.push(`plugin.json's icon is ${ICON_FILE}, and there is none beside it`);
|
|
44
|
+
else errors.push(...checkIcon(readFileSync(join(dir, ICON_FILE), "utf8")));
|
|
45
|
+
}
|
|
41
46
|
if (!["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"].some((f) => existsSync(join(dir, f)))) errors.push("no LICENSE: say how others may use it (the scaffold's is MIT)");
|
|
42
47
|
if (existsSync(join(dir, ".npmrc"))) errors.push("there is an .npmrc: the registry installs from registry.npmjs.org only, so remove it");
|
|
43
48
|
const lockAt = join(dir, "package-lock.json");
|
|
@@ -72,10 +77,10 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
|
|
|
72
77
|
if (!npm) { console.error(" ✗ npm was not found beside node"); process.exit(1); }
|
|
73
78
|
run(process.execPath, [npm, "ci", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
|
74
79
|
await buildPlugin(dir);
|
|
75
|
-
const files = ["plugin.json", "plugin.js", "mcp.js"].filter((f) => existsSync(join(dir, f)));
|
|
80
|
+
const files = ["plugin.json", "plugin.js", "mcp.js", ...(manifest.icon === ICON_FILE ? [ICON_FILE] : [])].filter((f) => existsSync(join(dir, f)));
|
|
76
81
|
errors.push(...checkAssets("plugin", files.map((f) => ({ name: f, size: statSync(join(dir, f)).size }))));
|
|
77
82
|
const table = JSON.parse(readFileSync(join(here, "permissions.json"), "utf8"));
|
|
78
|
-
const perms = checkPermissions(manifest.permissions, readFileSync(join(dir, "plugin.js"), "utf8"), table);
|
|
83
|
+
const perms = checkPermissions(manifest.permissions, readFileSync(join(dir, "plugin.js"), "utf8"), table, manifest.id);
|
|
79
84
|
errors.push(...perms.errors);
|
|
80
85
|
notes.push(...perms.notes);
|
|
81
86
|
if (errors.length) {
|
|
@@ -85,6 +90,17 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
|
|
|
85
90
|
|
|
86
91
|
say(`\n${manifest.name} ${manifest.version} is ready: ${files.join(", ")}${manifest.permissions.length ? `; it uses ${manifest.permissions.join(", ")}` : ""}.`);
|
|
87
92
|
for (const n of notes) say(` note: ${n}`);
|
|
93
|
+
|
|
94
|
+
// ---- its points, as the registry will give them ----
|
|
95
|
+
const read = (f) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : null);
|
|
96
|
+
const tsc = join(dir, "node_modules", "typescript", "bin", "tsc");
|
|
97
|
+
let typechecks = null;
|
|
98
|
+
if (!custom && existsSync(join(dir, "tsconfig.json")) && existsSync(tsc)) typechecks = ok(process.execPath, [tsc, "--noEmit", "-p", "."]);
|
|
99
|
+
let newestSdk = null;
|
|
100
|
+
try { newestSdk = (await (await fetch("https://registry.npmjs.org/deck-plugin-sdk/latest")).json()).version ?? null; } catch { /* offline: scored as not current */ }
|
|
101
|
+
const score = points({ ...fileFacts(read), buildVerified: !custom, typechecks, permissions: manifest.permissions, used: perms.used, newestSdk });
|
|
102
|
+
say(`\nPoints: ${score.total} of 100`);
|
|
103
|
+
for (const c of score.checks) say(` ${c.got ? "✓" : "✗"} ${c.label} (${c.got}/${c.max})${c.fix ? ` — ${c.fix}` : ""}`);
|
|
88
104
|
if (dryRun) {
|
|
89
105
|
say(`\nDry run: nothing was tagged, released or submitted. Without --dry-run this tags ${tag}, releases it on ${repo}, and opens a PR to ${REGISTRY}.`);
|
|
90
106
|
return;
|
package/template/plugin.json
CHANGED
package/types/core/bridge.d.ts
CHANGED
|
@@ -844,7 +844,8 @@ export declare const remoteHome: (host: string, sudo: boolean) => Promise<string
|
|
|
844
844
|
export declare const sshHosts: () => Promise<string[]>;
|
|
845
845
|
export declare const fsWrite: (path: string, content: string) => Promise<void>;
|
|
846
846
|
/** Raw bytes — a PDF, an image, a response — which a string would corrupt. Sent as the raw body,
|
|
847
|
-
* the path percent-encoded in a header; a remote path writes over SSH.
|
|
847
|
+
* the path percent-encoded in a header; a remote path writes over SSH. A local write makes a
|
|
848
|
+
* missing folder first, so a plugin's dataDir() needs no fsCreateDir. */
|
|
848
849
|
export declare const fsWriteBytes: (path: string, bytes: Uint8Array) => Promise<void>;
|
|
849
850
|
/** Write bytes without ever clobbering: dedupes the name on collision, returns the path used. */
|
|
850
851
|
export declare const fsSaveUnique: (saveTo: string, defaultName: string, bytes: Uint8Array) => Promise<string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
|
|
2
|
+
/** One block, by the 1-based line numbers of its markers. */
|
|
3
|
+
export interface ConflictBlock {
|
|
4
|
+
start: number;
|
|
5
|
+
/** The `|||||||` line of a diff3 block, else null. */
|
|
6
|
+
base: number | null;
|
|
7
|
+
mid: number;
|
|
8
|
+
end: number;
|
|
9
|
+
}
|
|
10
|
+
export type ConflictPick = "current" | "incoming" | "both";
|
|
11
|
+
/** Every complete block, in order. A block left unclosed is not one: nothing is offered for it. */
|
|
12
|
+
export declare function findConflicts(lines: string[]): ConflictBlock[];
|
|
13
|
+
/** The lines a block resolves to: one side, or both with the current change first. The base never. */
|
|
14
|
+
export declare function resolution(lines: string[], b: ConflictBlock, pick: ConflictPick): string[];
|
|
15
|
+
/**
|
|
16
|
+
* The edit replacing block `b` with `with`, as a 1-based range (start line and column, end line
|
|
17
|
+
* and column) and its text. Whole lines go, end marker included; when nothing is kept the block's
|
|
18
|
+
* lines are removed rather than left as one empty line. `lastCol(n)` is line n's last column.
|
|
19
|
+
*/
|
|
20
|
+
export declare function conflictEdit(lineCount: number, lastCol: (n: number) => number, b: ConflictBlock, keep: string[], eol: string): {
|
|
21
|
+
range: readonly [number, number, number, number];
|
|
22
|
+
text: string;
|
|
23
|
+
};
|
package/validate.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export const RESERVED = new Set(["terminals", "editor", "docs", "settings", "dec
|
|
|
11
11
|
* Packages the registry's rebuild executes. Each must be the real one from npm, since the rebuild
|
|
12
12
|
* runs on a machine that can merge; see the SECURITY note in deck-registry's tools/validate.mjs.
|
|
13
13
|
*/
|
|
14
|
-
export const BUILD_RUNS = ["deck-plugin-sdk", "esbuild", "react", "lucide-react", "framer-motion"];
|
|
14
|
+
export const BUILD_RUNS = ["deck-plugin-sdk", "esbuild", "react", "lucide-react", "framer-motion", "typescript"];
|
|
15
15
|
export const MAX_FILE = 10 * 1024 * 1024, MAX_TOTAL = 15 * 1024 * 1024;
|
|
16
16
|
export const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
17
17
|
export const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
@@ -44,13 +44,41 @@ export function checkManifest(m) {
|
|
|
44
44
|
if (typeof m.version !== "string" || !SEMVER.test(m.version)) errors.push("`version` must be a version, such as \"1.0.0\"");
|
|
45
45
|
if (typeof m.minDeck !== "string" || !SEMVER.test(m.minDeck)) errors.push("`minDeck` must say which Deck it needs, such as \"1.8.0\"");
|
|
46
46
|
if (m.color !== undefined && !/^#[0-9a-f]{6}$/i.test(m.color)) errors.push("`color` must be #rrggbb");
|
|
47
|
+
if (typeof m.icon === "string" && m.icon.endsWith(".svg") && m.icon !== ICON_FILE) errors.push(`an icon of its own must be named ${ICON_FILE}`);
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- a plugin's own icon ----
|
|
52
|
+
// `"icon": "icon.svg"` in plugin.json, in place of a lucide name: a file in the package, drawn in
|
|
53
|
+
// one colour (the plugin's) as a mask, the way Deck draws its own icons, so it never runs and
|
|
54
|
+
// follows the theme. Only shapes are allowed in it.
|
|
55
|
+
|
|
56
|
+
/** The one file name a plugin's own icon may have. */
|
|
57
|
+
export const ICON_FILE = "icon.svg";
|
|
58
|
+
/** The largest icon accepted: an icon is a few shapes, and the catalogue carries it inline. */
|
|
59
|
+
export const MAX_ICON = 16 * 1024;
|
|
60
|
+
const ICON_TAGS = new Set(["svg", "g", "path", "circle", "rect", "line", "polyline", "polygon", "ellipse", "title", "desc"]);
|
|
61
|
+
|
|
62
|
+
/** What is wrong with an icon.svg's text: [] when it is only shapes, with a viewBox, small enough. */
|
|
63
|
+
export function checkIcon(svg) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
if (Buffer.byteLength(svg) > MAX_ICON) errors.push(`${ICON_FILE} is ${(Buffer.byteLength(svg) / 1024).toFixed(1)} KB; an icon may be 16 KB at most`);
|
|
66
|
+
const body = svg.replace(/^\uFEFF?\s*(<\?xml[^>]*\?>\s*)?/, "").replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
67
|
+
if (!/^<svg[\s>]/.test(body)) errors.push(`${ICON_FILE} must be an <svg> element`);
|
|
68
|
+
if (!/<svg[^>]*\sviewBox\s*=/.test(body)) errors.push(`${ICON_FILE} needs a viewBox, so it scales to the size it is drawn at`);
|
|
69
|
+
if (/<!|<\?/.test(body)) errors.push(`${ICON_FILE} may not hold a DOCTYPE, an entity or a processing instruction`);
|
|
70
|
+
const tags = [...new Set([...body.matchAll(/<\/?\s*([\w:-]+)/g)].map((m) => m[1]))].filter((t) => !ICON_TAGS.has(t));
|
|
71
|
+
if (tags.length) errors.push(`${ICON_FILE} may hold only shapes; remove ${tags.map((t) => `<${t}>`).join(", ")}`);
|
|
72
|
+
const attrs = [...new Set([...body.matchAll(/\s([\w:-]+)\s*=/g)].map((m) => m[1]))].filter((a) => /^on|^(href|xlink:href|style)$/i.test(a));
|
|
73
|
+
if (attrs.length) errors.push(`${ICON_FILE} may not have ${attrs.join(", ")}: no scripts, links or styles`);
|
|
74
|
+
if (/url\s*\(|javascript:|data:/i.test(body)) errors.push(`${ICON_FILE} may not point at anything outside itself (url(), data: or javascript:)`);
|
|
47
75
|
return errors;
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
/** The release's files: the allowed names, the required ones, and the size limits. */
|
|
51
79
|
export function checkAssets(kind, assets) {
|
|
52
80
|
const errors = [];
|
|
53
|
-
const ok = (n) => kind === "plugin" ? ["plugin.json", "plugin.js", "mcp.js"].includes(n) : n === "language.json" || /^snippets--[\w.-]+\.json$/.test(n);
|
|
81
|
+
const ok = (n) => kind === "plugin" ? ["plugin.json", "plugin.js", "mcp.js", ICON_FILE].includes(n) : n === "language.json" || /^snippets--[\w.-]+\.json$/.test(n);
|
|
54
82
|
for (const a of assets) if (!ok(a.name)) errors.push(`the release has \`${a.name}\`, which a ${kind} cannot contain`);
|
|
55
83
|
for (const need of kind === "plugin" ? ["plugin.json", "plugin.js"] : ["language.json"]) {
|
|
56
84
|
if (!assets.some((a) => a.name === need)) errors.push(`the release has no \`${need}\``);
|
|
@@ -86,8 +114,14 @@ export function checkLockfile(lock) {
|
|
|
86
114
|
* calls the bridge as `b().fsWrite(`, which minifying keeps, so `().fn(` is the pattern, and a
|
|
87
115
|
* method of the same name on something else (a RegExp's .exec) does not count. Reaching Deck's
|
|
88
116
|
* internals directly, around the bridge, is refused outright.
|
|
117
|
+
*
|
|
118
|
+
* With the plugin's `id`, a config named in full (`"state/term-procs"`, another
|
|
119
|
+
* plugin's `data/<id>/…`) needs "deck", as Deck's bridge asks at run time: a plain name is kept in
|
|
120
|
+
* the plugin's own folder, a name with a "/" is one of Deck's or another plugin's. Only names
|
|
121
|
+
* written out whole are seen (a name built at run time is not), and one only compared against, as
|
|
122
|
+
* a config-changed listener does, counts too: such a plugin almost always reads it.
|
|
89
123
|
*/
|
|
90
|
-
export function checkPermissions(declared, bundle, table) {
|
|
124
|
+
export function checkPermissions(declared, bundle, table, id) {
|
|
91
125
|
const errors = [], notes = [];
|
|
92
126
|
if (!Array.isArray(declared)) {
|
|
93
127
|
return { errors: ['plugin.json must say what it uses: "permissions": [...], or [] when it needs nothing beyond its own data and settings'], notes };
|
|
@@ -104,9 +138,89 @@ export function checkPermissions(declared, bundle, table) {
|
|
|
104
138
|
for (const [mod, p] of Object.entries(table.modules)) {
|
|
105
139
|
if (new RegExp(`__deck\\?\\.${mod}\\b`).test(bundle)) needs.set(p, [...(needs.get(p) ?? []), mod]);
|
|
106
140
|
}
|
|
141
|
+
if (id) {
|
|
142
|
+
// A minified bundle keeps the name but not the call's, so the names are what is looked for.
|
|
143
|
+
for (const [, name] of bundle.matchAll(/["'`]((?:settings|state|cache|secrets|logs)\/[\w./-]+|data\/[\w-]+\/[\w./-]*)["'`]/g)) {
|
|
144
|
+
const seen = needs.get("deck") ?? [], what = `the config "${name}"`;
|
|
145
|
+
if (!name.startsWith(`data/${id}/`) && !seen.includes(what)) needs.set("deck", [...seen, what]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
107
148
|
for (const [p, fns] of needs) {
|
|
108
149
|
if (!declared.includes(p)) errors.push(`it uses ${fns.slice(0, 4).join(", ")}${fns.length > 4 ? ` and ${fns.length - 4} more` : ""}, which ${fns.length > 1 ? "need" : "needs"} the "${p}" permission; add it to "permissions"`);
|
|
109
150
|
}
|
|
110
151
|
if (/\beval\(|new Function\(/.test(bundle)) notes.push("Its bundle uses eval or new Function, which runs code made at run time; a library may do this legitimately.");
|
|
111
|
-
return { errors, notes };
|
|
152
|
+
return { errors, notes, used: [...needs.keys()] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- points ----
|
|
156
|
+
// A version's points: advice for whoever is choosing a plugin, never a gate (the checks above are
|
|
157
|
+
// that). Worked out once when it is published, by the registry for a submission and by
|
|
158
|
+
// `deck-plugin publish --dry-run` on the author's machine, from the same facts, so they agree.
|
|
159
|
+
|
|
160
|
+
/** The files a licence may be in, in the order they are looked for. */
|
|
161
|
+
export const LICENSE_FILES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
162
|
+
/** Open-source licences, each by a phrase its text always has. */
|
|
163
|
+
const LICENSES = [
|
|
164
|
+
["MIT", /Permission is hereby granted, free of charge/i],
|
|
165
|
+
["Apache-2.0", /Apache License[\s\S]{0,40}Version 2\.0/i],
|
|
166
|
+
["BSD", /Redistribution and use in source and binary forms/i],
|
|
167
|
+
["ISC", /Permission to use, copy, modify, and\/or distribute this software for any\s+purpose/i],
|
|
168
|
+
["MPL-2.0", /Mozilla Public License,? (?:v\. |Version )2\.0/i],
|
|
169
|
+
["GPL", /GNU (?:Affero |Lesser )?General Public License/i],
|
|
170
|
+
["Unlicense", /This is free and unencumbered software released into the public domain/i],
|
|
171
|
+
];
|
|
172
|
+
export const licenseOf = (text) => LICENSES.find(([, re]) => re.test(text))?.[0] ?? null;
|
|
173
|
+
/** A README that shows the plugin: a markdown or HTML image, or a video. */
|
|
174
|
+
export const showsMedia = (md) => /!\[[^\]]*\]\(\s*[^)\s]|<img\s[^>]*src=|<video\s/i.test(md);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The facts that need only the package's files. `read(name)` gives a file's text at the tag, or
|
|
178
|
+
* null when there is none.
|
|
179
|
+
*/
|
|
180
|
+
export function fileFacts(read) {
|
|
181
|
+
const licence = LICENSE_FILES.map(read).find((t) => t != null);
|
|
182
|
+
let lock = null;
|
|
183
|
+
try { lock = JSON.parse(read("package-lock.json") ?? "null"); } catch { /* not a lockfile, so none */ }
|
|
184
|
+
return {
|
|
185
|
+
license: licence == null ? null : licenseOf(licence),
|
|
186
|
+
changelog: read("CHANGELOG.md") != null,
|
|
187
|
+
readmeMedia: showsMedia(read("README.md") ?? ""),
|
|
188
|
+
lockfile: !!lock && !checkLockfile(lock).length,
|
|
189
|
+
sdk: lock?.packages?.["node_modules/deck-plugin-sdk"]?.version ?? null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** How many minor versions `v` is behind `newest`: Infinity across a major. */
|
|
194
|
+
function minorsBehind(v, newest) {
|
|
195
|
+
const [a, b] = [v, newest].map((x) => x.split("-")[0].split(".").map(Number));
|
|
196
|
+
if (a[0] !== b[0]) return a[0] > b[0] ? 0 : Infinity;
|
|
197
|
+
return Math.max(0, b[1] - a[1]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A version's points out of 100, check by check. `f` is fileFacts plus what the registry learns by
|
|
202
|
+
* building it: buildVerified, typechecks (true, false, or null when it could not be run), the
|
|
203
|
+
* permissions declared (null when plugin.json has none) and used, and the newest SDK on npm.
|
|
204
|
+
* A check not earned says how to earn it.
|
|
205
|
+
*/
|
|
206
|
+
export function points(f) {
|
|
207
|
+
const unused = f.permissions ? f.permissions.filter((p) => !f.used.includes(p)) : [];
|
|
208
|
+
const checks = [
|
|
209
|
+
["readme-media", "The README shows it, with a screenshot or other image", 10, f.readmeMedia,
|
|
210
|
+
"Add a screenshot to README.md"],
|
|
211
|
+
["changelog", "It has a changelog", 10, f.changelog, "Add a CHANGELOG.md saying what each version changed"],
|
|
212
|
+
["license", "It has an open-source licence", 10, !!f.license, "Add a LICENSE with an open-source licence, such as MIT"],
|
|
213
|
+
["lockfile", "Its lockfile installs only from npm, every package with a hash", 10, f.lockfile,
|
|
214
|
+
"Commit a package-lock.json made by npm 7 or newer, with nothing linked or from elsewhere"],
|
|
215
|
+
["build-verified", "The registry rebuilt it from its source and got the same files", 10, f.buildVerified,
|
|
216
|
+
"Release what `npm ci && npm run build` makes at the tag, with no deck-plugin.config.mjs"],
|
|
217
|
+
["permissions", "It says what it uses", 10, Array.isArray(f.permissions), "List what it uses in plugin.json's \"permissions\""],
|
|
218
|
+
["least-privilege", "It asks for nothing it does not use", 10, Array.isArray(f.permissions) && !unused.length,
|
|
219
|
+
unused.length ? `Remove ${unused.join(", ")} from "permissions": its code never uses ${unused.length > 1 ? "them" : "it"}` : "List what it uses in plugin.json's \"permissions\""],
|
|
220
|
+
["typechecks", "It type-checks", 20, f.typechecks === true,
|
|
221
|
+
f.typechecks === null ? "Let the registry rebuild it, with a tsconfig.json, so it can be type-checked" : "Make `npx tsc --noEmit` pass"],
|
|
222
|
+
["up-to-date", "It is built with a current SDK", 10, !!(f.sdk && f.newestSdk && minorsBehind(f.sdk, f.newestSdk) <= 1),
|
|
223
|
+
`Build it with deck-plugin-sdk ${f.newestSdk ?? "at its newest"}`],
|
|
224
|
+
].map(([id, label, max, ok, fix]) => ({ id, label, max, got: ok ? max : 0, ...(ok ? {} : { fix }) }));
|
|
225
|
+
return { total: checks.reduce((n, c) => n + c.got, 0), checks };
|
|
112
226
|
}
|