velora-plugin 0.9.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/LICENSE +202 -0
- package/README.md +22 -0
- package/dist/build-federation-bundle.d.ts +29 -0
- package/dist/build-federation-bundle.js +93 -0
- package/dist/build.d.ts +41 -0
- package/dist/build.js +123 -0
- package/dist/bundle-server-manifest.d.ts +27 -0
- package/dist/bundle-server-manifest.js +93 -0
- package/dist/checks/audit.d.ts +2 -0
- package/dist/checks/audit.js +50 -0
- package/dist/checks/bundle-size.d.ts +3 -0
- package/dist/checks/bundle-size.js +21 -0
- package/dist/checks/db-compat.d.ts +3 -0
- package/dist/checks/db-compat.js +15 -0
- package/dist/checks/manifest.d.ts +3 -0
- package/dist/checks/manifest.js +12 -0
- package/dist/checks/shared-deps.d.ts +3 -0
- package/dist/checks/shared-deps.js +31 -0
- package/dist/checks/typescript.d.ts +2 -0
- package/dist/checks/typescript.js +34 -0
- package/dist/checks/views.d.ts +3 -0
- package/dist/checks/views.js +63 -0
- package/dist/copy-sample-content.d.ts +39 -0
- package/dist/copy-sample-content.js +57 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +159 -0
- package/dist/load-manifest.d.ts +18 -0
- package/dist/load-manifest.js +49 -0
- package/dist/pack-starter.d.ts +48 -0
- package/dist/pack-starter.js +118 -0
- package/dist/pack.d.ts +12 -0
- package/dist/pack.js +26 -0
- package/dist/publish.d.ts +24 -0
- package/dist/publish.js +48 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +5 -0
- package/dist/validate.js +49 -0
- package/dist/write-manifest-json.d.ts +7 -0
- package/dist/write-manifest-json.js +72 -0
- package/package.json +54 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function checkNpmAudit(pluginDir) {
|
|
5
|
+
const name = "npm audit";
|
|
6
|
+
// In-monorepo reference plugins (plugins/google-maps) rely on the root
|
|
7
|
+
// pnpm-lock.yaml and have no lockfile of their own — audit is only
|
|
8
|
+
// meaningful against a real npm-managed project (a scaffolded,
|
|
9
|
+
// `npm install`ed external plugin), the actual target of this check.
|
|
10
|
+
if (!existsSync(join(pluginDir, "package-lock.json"))) {
|
|
11
|
+
return { name, passed: true, message: "skipped — no package-lock.json (not an npm-managed project)" };
|
|
12
|
+
}
|
|
13
|
+
let stdout;
|
|
14
|
+
try {
|
|
15
|
+
// shell: true is required so this resolves npm.cmd on Windows (npm is a
|
|
16
|
+
// .cmd shim there, and execFileSync cannot exec .cmd files without a
|
|
17
|
+
// shell — it fails immediately with ENOENT otherwise). Safe here: both
|
|
18
|
+
// the command ("npm") and args (["audit", "--json"]) are static
|
|
19
|
+
// literals, never user input, so there's no shell-injection risk from
|
|
20
|
+
// enabling this. This matches the shell: true convention already used
|
|
21
|
+
// for spawnSync calls in packages/velora-dev/src/{docker,sandbox}.ts.
|
|
22
|
+
stdout = execFileSync("npm", ["audit", "--json"], { cwd: pluginDir, encoding: "utf8", shell: true });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// npm audit exits non-zero when it FINDS vulnerabilities — that's a
|
|
26
|
+
// normal result carried on stdout, not a crash.
|
|
27
|
+
stdout = error.stdout ?? "";
|
|
28
|
+
}
|
|
29
|
+
let report;
|
|
30
|
+
try {
|
|
31
|
+
report = JSON.parse(stdout);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { name, passed: true, message: "skipped — npm audit did not return parseable JSON" };
|
|
35
|
+
}
|
|
36
|
+
const high = report.metadata?.vulnerabilities?.high ?? 0;
|
|
37
|
+
const critical = report.metadata?.vulnerabilities?.critical ?? 0;
|
|
38
|
+
if (high + critical === 0) {
|
|
39
|
+
return { name, passed: true, message: "0 high/critical advisories" };
|
|
40
|
+
}
|
|
41
|
+
const offenders = Object.entries(report.vulnerabilities ?? {})
|
|
42
|
+
.filter(([, advisory]) => advisory.severity === "high" || advisory.severity === "critical")
|
|
43
|
+
.map(([pkgName, advisory]) => `${pkgName} (${advisory.severity})`);
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
passed: false,
|
|
47
|
+
message: `${high + critical} high/critical advisor${high + critical === 1 ? "y" : "ies"}`,
|
|
48
|
+
details: offenders.join("\n"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
const LIMIT_BYTES = 250 * 1024;
|
|
4
|
+
function formatKb(bytes) {
|
|
5
|
+
return `${Math.ceil(bytes / 1024)} KB`;
|
|
6
|
+
}
|
|
7
|
+
export function checkBundleSize(build) {
|
|
8
|
+
const name = "bundle size";
|
|
9
|
+
if (!build.remoteEntryPath) {
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
passed: false,
|
|
13
|
+
message: build.buildError ? `federation build failed: ${build.buildError}` : "no remoteEntry.js produced by the build",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const gzipped = gzipSync(readFileSync(build.remoteEntryPath)).length;
|
|
17
|
+
if (gzipped > LIMIT_BYTES) {
|
|
18
|
+
return { name, passed: false, message: `${formatKb(gzipped)} gzipped exceeds the ${formatKb(LIMIT_BYTES)} limit` };
|
|
19
|
+
}
|
|
20
|
+
return { name, passed: true, message: `${formatKb(gzipped)} gzipped (limit ${formatKb(LIMIT_BYTES)})` };
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function checkDatabaseCompatibility(result) {
|
|
2
|
+
const name = "database compatibility declared";
|
|
3
|
+
if (!result.ok) {
|
|
4
|
+
return { name, passed: false, message: "cannot check — manifest schema invalid" };
|
|
5
|
+
}
|
|
6
|
+
// Zod's databaseCompatibility.supported.min(1) already guarantees this
|
|
7
|
+
// is non-empty whenever result.ok is true — this check exists as its
|
|
8
|
+
// own named, reported gate per the Session 80 checklist, not because
|
|
9
|
+
// new logic is needed beyond what manifest validity already enforces.
|
|
10
|
+
const supported = result.manifest.databaseCompatibility.supported;
|
|
11
|
+
if (supported.length === 0) {
|
|
12
|
+
return { name, passed: false, message: "databaseCompatibility.supported is empty" };
|
|
13
|
+
}
|
|
14
|
+
return { name, passed: true, message: `declared: ${supported.join(", ")}` };
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function checkManifest(result) {
|
|
2
|
+
const name = "manifest schema valid";
|
|
3
|
+
if (result.ok) {
|
|
4
|
+
return { name, passed: true, message: "manifest schema valid" };
|
|
5
|
+
}
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
passed: false,
|
|
9
|
+
message: `manifest schema invalid (${result.errors.length} issue${result.errors.length === 1 ? "" : "s"})`,
|
|
10
|
+
details: result.errors.join("\n"),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
// Symbol.for('react.element') is a literal string in React's own source
|
|
4
|
+
// (react-is/react internals) that survives minification — its presence
|
|
5
|
+
// in a built federation bundle means react's actual runtime code was
|
|
6
|
+
// inlined rather than shared, even if package.json superficially
|
|
7
|
+
// declares it correctly.
|
|
8
|
+
const REACT_TELLTALE = "react.element";
|
|
9
|
+
export function checkSharedDeps(pluginDir, build) {
|
|
10
|
+
const name = "react/react-dom not bundled";
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"));
|
|
12
|
+
const misplaced = ["react", "react-dom"].filter((dep) => pkg.dependencies?.[dep]);
|
|
13
|
+
if (misplaced.length > 0) {
|
|
14
|
+
return {
|
|
15
|
+
name,
|
|
16
|
+
passed: false,
|
|
17
|
+
message: `${misplaced.join(", ")} declared under "dependencies" — must be "peerDependencies" (or absent) so the host's shared singleton is used`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (build.remoteEntryPath) {
|
|
21
|
+
const bundleSource = readFileSync(build.remoteEntryPath, "utf8");
|
|
22
|
+
if (bundleSource.includes(REACT_TELLTALE)) {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
passed: false,
|
|
26
|
+
message: "the built bundle contains React's own source — react is being bundled instead of shared",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { name, passed: true, message: "react/react-dom are peer/shared, not bundled" };
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export function checkTypeScript(pluginDir) {
|
|
3
|
+
const name = "typescript compiles";
|
|
4
|
+
const configPath = ts.findConfigFile(pluginDir, ts.sys.fileExists, "tsconfig.json");
|
|
5
|
+
if (!configPath) {
|
|
6
|
+
return { name, passed: false, message: `no tsconfig.json found in ${pluginDir}` };
|
|
7
|
+
}
|
|
8
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
9
|
+
if (configFile.error) {
|
|
10
|
+
return { name, passed: false, message: ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n") };
|
|
11
|
+
}
|
|
12
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, pluginDir);
|
|
13
|
+
const program = ts.createProgram({
|
|
14
|
+
rootNames: parsed.fileNames,
|
|
15
|
+
options: { ...parsed.options, noEmit: true },
|
|
16
|
+
});
|
|
17
|
+
const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)];
|
|
18
|
+
if (diagnostics.length === 0) {
|
|
19
|
+
return { name, passed: true, message: "no type errors" };
|
|
20
|
+
}
|
|
21
|
+
const details = diagnostics
|
|
22
|
+
.map((diagnostic) => ts.formatDiagnostic(diagnostic, {
|
|
23
|
+
getCurrentDirectory: () => pluginDir,
|
|
24
|
+
getCanonicalFileName: (file) => file,
|
|
25
|
+
getNewLine: () => "\n",
|
|
26
|
+
}))
|
|
27
|
+
.join("");
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
passed: false,
|
|
31
|
+
message: `${diagnostics.length} type error${diagnostics.length === 1 ? "" : "s"}`,
|
|
32
|
+
details,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Mirrors the contribution matrix (docs/PROJECT_CONTEXT.md → Plugin
|
|
2
|
+
// System). A schema-valid manifest can still declare an empty
|
|
3
|
+
// contributions object for its type (every key in the matrix is
|
|
4
|
+
// .optional()) — that's a useless plugin, and this check is the one
|
|
5
|
+
// place that catches it. datatype's per-view requirement
|
|
6
|
+
// (input/readOnly/settings) is ALREADY structurally enforced by
|
|
7
|
+
// DataTypePluginSchema (only "preview" is optional there), so a
|
|
8
|
+
// schema-valid datatype manifest can never be missing one — reaching
|
|
9
|
+
// "passed: true" below already proves it.
|
|
10
|
+
const REQUIRED_ONE_OF = {
|
|
11
|
+
datatype: ["dataTypes", "mediaProcessors", "settingsPages"],
|
|
12
|
+
section: [
|
|
13
|
+
"dataTypes",
|
|
14
|
+
"adminSections",
|
|
15
|
+
"apiRoutes",
|
|
16
|
+
"dashboardWidgets",
|
|
17
|
+
"hooks",
|
|
18
|
+
"scheduledTasks",
|
|
19
|
+
"settingsPages",
|
|
20
|
+
"documentTypes",
|
|
21
|
+
"bundledTemplate",
|
|
22
|
+
],
|
|
23
|
+
template: ["documentTypes"],
|
|
24
|
+
theme: ["themeTokens"],
|
|
25
|
+
integration: ["hooks", "settingsPages", "exposedServices"],
|
|
26
|
+
utility: [
|
|
27
|
+
"dashboardWidgets",
|
|
28
|
+
"hooks",
|
|
29
|
+
"scheduledTasks",
|
|
30
|
+
"mediaProcessors",
|
|
31
|
+
"settingsPages",
|
|
32
|
+
"contentTreeActions",
|
|
33
|
+
"editorToolbar",
|
|
34
|
+
],
|
|
35
|
+
bundle: ["bundledPlugins", "bundledTemplate"],
|
|
36
|
+
};
|
|
37
|
+
function hasContent(value) {
|
|
38
|
+
if (value == null)
|
|
39
|
+
return false;
|
|
40
|
+
if (Array.isArray(value))
|
|
41
|
+
return value.length > 0;
|
|
42
|
+
if (typeof value === "object")
|
|
43
|
+
return Object.keys(value).length > 0;
|
|
44
|
+
return Boolean(value);
|
|
45
|
+
}
|
|
46
|
+
export function checkRequiredViews(result) {
|
|
47
|
+
const name = "required views/contributions present";
|
|
48
|
+
if (!result.ok) {
|
|
49
|
+
return { name, passed: false, message: "cannot check — manifest schema invalid" };
|
|
50
|
+
}
|
|
51
|
+
const manifest = result.manifest;
|
|
52
|
+
const requiredKeys = REQUIRED_ONE_OF[manifest.type];
|
|
53
|
+
const contributions = manifest.contributions;
|
|
54
|
+
const present = requiredKeys.filter((key) => hasContent(contributions[key]));
|
|
55
|
+
if (present.length === 0) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
passed: false,
|
|
59
|
+
message: `${manifest.type} plugin declares no contributions (expected at least one of: ${requiredKeys.join(", ")})`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { name, passed: true, message: `contributes: ${present.join(", ")}` };
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CMSPlugin } from "@velora-cms/plugin-sdk/manifest";
|
|
2
|
+
/**
|
|
3
|
+
* Same legality product as packStarterIfPresent's UndeclaredStarterError
|
|
4
|
+
* (pack-starter.ts): an undeclared package data file is treated as a
|
|
5
|
+
* mistake, not a choice. sample-content.json only means something to a
|
|
6
|
+
* starter-bearing plugin — it's the seed tree
|
|
7
|
+
* apps/server/src/plugins/seed-template-content.ts (S149 Task 2) creates
|
|
8
|
+
* host-side after install, keyed to the SAME plugin's own documentTypes
|
|
9
|
+
* contribution. A sample-content.json sitting next to a non-starter-
|
|
10
|
+
* bearing manifest can never be consumed by anything.
|
|
11
|
+
*/
|
|
12
|
+
export declare class UndeclaredSampleContentError extends Error {
|
|
13
|
+
constructor(pluginDir: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Copies <pluginDir>/sample-content.json into <outDir>/sample-content.json
|
|
17
|
+
* when present — the S149 delivery mechanism for a template's seed
|
|
18
|
+
* content. Host-side seeding (D1 of the S149 plan) needs the file to
|
|
19
|
+
* survive INSIDE the packed artifact: onInstall runs with no minted ids
|
|
20
|
+
* and no content API, so the actual node-creation walk has to happen
|
|
21
|
+
* AFTER install(), reading this file back out of the STORED package
|
|
22
|
+
* directory (apps/server/src/plugins/seed-template-content.ts, Task 2) —
|
|
23
|
+
* plain readFileSync, no separate tar entry extraction. packPlugin tars
|
|
24
|
+
* the whole outDir as-is (pack.ts), so once the file lands in outDir it
|
|
25
|
+
* rides into the artifact for free, exactly like starter.tar.gz.
|
|
26
|
+
*
|
|
27
|
+
* Rules (mirrors packStarterIfPresent's structure exactly):
|
|
28
|
+
* - sample-content.json absent → no-op, regardless of starter-bearing
|
|
29
|
+
* status (a starter-bearing plugin with no sample content stays legal
|
|
30
|
+
* — not every template ships seed data).
|
|
31
|
+
* - sample-content.json present, NOT starter-bearing → throws
|
|
32
|
+
* UndeclaredSampleContentError.
|
|
33
|
+
* - sample-content.json present and starter-bearing → copied verbatim
|
|
34
|
+
* (no parsing/validation here — Task 2's seeder owns shape validation
|
|
35
|
+
* against the STORED copy; build-time is a pure file carry).
|
|
36
|
+
*
|
|
37
|
+
* Returns the copied file's path, or null when nothing was copied.
|
|
38
|
+
*/
|
|
39
|
+
export declare function copySampleContentIfPresent(pluginDir: string, outDir: string, manifest: CMSPlugin): string | null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { copyFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isStarterBearing } from "./pack-starter.js";
|
|
4
|
+
/**
|
|
5
|
+
* Same legality product as packStarterIfPresent's UndeclaredStarterError
|
|
6
|
+
* (pack-starter.ts): an undeclared package data file is treated as a
|
|
7
|
+
* mistake, not a choice. sample-content.json only means something to a
|
|
8
|
+
* starter-bearing plugin — it's the seed tree
|
|
9
|
+
* apps/server/src/plugins/seed-template-content.ts (S149 Task 2) creates
|
|
10
|
+
* host-side after install, keyed to the SAME plugin's own documentTypes
|
|
11
|
+
* contribution. A sample-content.json sitting next to a non-starter-
|
|
12
|
+
* bearing manifest can never be consumed by anything.
|
|
13
|
+
*/
|
|
14
|
+
export class UndeclaredSampleContentError extends Error {
|
|
15
|
+
constructor(pluginDir) {
|
|
16
|
+
super(`${pluginDir} has a sample-content.json but its manifest is not starter-bearing (see ` +
|
|
17
|
+
`pack-starter.ts's isStarterBearing — a 'template'-type plugin, or a section/bundle plugin ` +
|
|
18
|
+
`declaring contributions.bundledTemplate). An undeclared sample-content.json is treated as a ` +
|
|
19
|
+
`mistake, not a choice — either make the plugin starter-bearing or remove the file.`);
|
|
20
|
+
this.name = "UndeclaredSampleContentError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Copies <pluginDir>/sample-content.json into <outDir>/sample-content.json
|
|
25
|
+
* when present — the S149 delivery mechanism for a template's seed
|
|
26
|
+
* content. Host-side seeding (D1 of the S149 plan) needs the file to
|
|
27
|
+
* survive INSIDE the packed artifact: onInstall runs with no minted ids
|
|
28
|
+
* and no content API, so the actual node-creation walk has to happen
|
|
29
|
+
* AFTER install(), reading this file back out of the STORED package
|
|
30
|
+
* directory (apps/server/src/plugins/seed-template-content.ts, Task 2) —
|
|
31
|
+
* plain readFileSync, no separate tar entry extraction. packPlugin tars
|
|
32
|
+
* the whole outDir as-is (pack.ts), so once the file lands in outDir it
|
|
33
|
+
* rides into the artifact for free, exactly like starter.tar.gz.
|
|
34
|
+
*
|
|
35
|
+
* Rules (mirrors packStarterIfPresent's structure exactly):
|
|
36
|
+
* - sample-content.json absent → no-op, regardless of starter-bearing
|
|
37
|
+
* status (a starter-bearing plugin with no sample content stays legal
|
|
38
|
+
* — not every template ships seed data).
|
|
39
|
+
* - sample-content.json present, NOT starter-bearing → throws
|
|
40
|
+
* UndeclaredSampleContentError.
|
|
41
|
+
* - sample-content.json present and starter-bearing → copied verbatim
|
|
42
|
+
* (no parsing/validation here — Task 2's seeder owns shape validation
|
|
43
|
+
* against the STORED copy; build-time is a pure file carry).
|
|
44
|
+
*
|
|
45
|
+
* Returns the copied file's path, or null when nothing was copied.
|
|
46
|
+
*/
|
|
47
|
+
export function copySampleContentIfPresent(pluginDir, outDir, manifest) {
|
|
48
|
+
const sourcePath = join(pluginDir, "sample-content.json");
|
|
49
|
+
if (!existsSync(sourcePath))
|
|
50
|
+
return null;
|
|
51
|
+
if (!isStarterBearing(manifest)) {
|
|
52
|
+
throw new UndeclaredSampleContentError(pluginDir);
|
|
53
|
+
}
|
|
54
|
+
const destPath = join(outDir, "sample-content.json");
|
|
55
|
+
copyFileSync(sourcePath, destPath);
|
|
56
|
+
return destPath;
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { ValidationResult } from "./types.js";
|
|
3
|
+
import { type PublishResult } from "./publish.js";
|
|
4
|
+
export declare function formatResult(result: ValidationResult): string;
|
|
5
|
+
/** Pure rendering for `publishPlugin`'s ok branch — split out so the CLI's
|
|
6
|
+
* console/exitCode wiring is the only untested shell. */
|
|
7
|
+
export declare function formatPublishOutcome(result: Extract<PublishResult, {
|
|
8
|
+
ok: true;
|
|
9
|
+
}>): {
|
|
10
|
+
text: string;
|
|
11
|
+
failed: boolean;
|
|
12
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { validatePlugin } from "./validate.js";
|
|
5
|
+
import { buildPlugin, BuildValidationError } from "./build.js";
|
|
6
|
+
import { packPlugin } from "./pack.js";
|
|
7
|
+
import { publishPlugin } from "./publish.js";
|
|
8
|
+
export function formatResult(result) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const check of result.checks) {
|
|
11
|
+
const mark = check.passed ? "✓" : "✗";
|
|
12
|
+
lines.push(`${mark} ${check.name} (${check.message})`);
|
|
13
|
+
if (!check.passed && check.details) {
|
|
14
|
+
lines.push(check.details
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((line) => ` ${line}`)
|
|
17
|
+
.join("\n"));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const failed = result.checks.filter((check) => !check.passed).length;
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push(failed === 0
|
|
23
|
+
? `All ${result.checks.length} check${result.checks.length === 1 ? "" : "s"} passed.`
|
|
24
|
+
: `${failed} check${failed === 1 ? "" : "s"} failed.`);
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
/** Pure rendering for `publishPlugin`'s ok branch — split out so the CLI's
|
|
28
|
+
* console/exitCode wiring is the only untested shell. */
|
|
29
|
+
export function formatPublishOutcome(result) {
|
|
30
|
+
if (result.state === "validation_failed") {
|
|
31
|
+
const lines = [`Published: submission ${result.submissionId} — validation failed`];
|
|
32
|
+
for (const name of result.failedChecks ?? [])
|
|
33
|
+
lines.push(` ✗ ${name}`);
|
|
34
|
+
return { text: lines.join("\n"), failed: true };
|
|
35
|
+
}
|
|
36
|
+
if (result.state === "awaiting_review") {
|
|
37
|
+
return { text: `Published: submission ${result.submissionId} — validation passed, awaiting review`, failed: false };
|
|
38
|
+
}
|
|
39
|
+
return { text: `Published: submission ${result.submissionId} (state: ${result.state})`, failed: false };
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const { values, positionals } = parseArgs({
|
|
43
|
+
allowPositionals: true,
|
|
44
|
+
options: {
|
|
45
|
+
json: { type: "boolean", default: false },
|
|
46
|
+
out: { type: "string" },
|
|
47
|
+
registry: { type: "string" },
|
|
48
|
+
token: { type: "string" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const [, command, dirArg] = ["velora-plugin", ...positionals];
|
|
52
|
+
const pluginDir = dirArg ?? process.cwd();
|
|
53
|
+
switch (command) {
|
|
54
|
+
case "validate": {
|
|
55
|
+
const result = await validatePlugin(pluginDir);
|
|
56
|
+
if (values.json) {
|
|
57
|
+
console.log(JSON.stringify(result, null, 2));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(formatResult(result));
|
|
61
|
+
}
|
|
62
|
+
process.exitCode = result.passed ? 0 : 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case "build": {
|
|
66
|
+
try {
|
|
67
|
+
const result = await buildPlugin(pluginDir, values.out);
|
|
68
|
+
console.log(`Built ${result.outDir}`);
|
|
69
|
+
process.exitCode = 0;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof BuildValidationError) {
|
|
73
|
+
console.error(formatResult(error.validation));
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case "pack": {
|
|
82
|
+
try {
|
|
83
|
+
const result = await packPlugin(pluginDir, values.out);
|
|
84
|
+
console.log(`Packed ${result.archivePath}`);
|
|
85
|
+
process.exitCode = 0;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof BuildValidationError) {
|
|
89
|
+
console.error(formatResult(error.validation));
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
case "publish": {
|
|
98
|
+
const registryUrl = values.registry ?? process.env.VELORA_REGISTRY_URL ?? "http://localhost:3200";
|
|
99
|
+
const token = values.token ?? process.env.VELORA_PUBLISH_TOKEN;
|
|
100
|
+
if (!token) {
|
|
101
|
+
console.error("set VELORA_PUBLISH_TOKEN or pass --token");
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const result = await publishPlugin(pluginDir, { registryUrl, token });
|
|
107
|
+
if (result.ok) {
|
|
108
|
+
const outcome = formatPublishOutcome(result);
|
|
109
|
+
if (outcome.failed) {
|
|
110
|
+
console.error(outcome.text);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.log(outcome.text);
|
|
115
|
+
process.exitCode = 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// publishPlugin maps a local validation failure into this
|
|
120
|
+
// ok:false branch (a joined check-names message) rather than
|
|
121
|
+
// throwing BuildValidationError, so this is the path a failed
|
|
122
|
+
// validate actually takes today.
|
|
123
|
+
console.error(result.message);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// Kept for parity with the build/pack cases' check-by-check
|
|
129
|
+
// output and as a safety net — publishPlugin does not currently
|
|
130
|
+
// let BuildValidationError escape (it catches and maps it above),
|
|
131
|
+
// so this branch is not expected to be exercised in practice.
|
|
132
|
+
if (error instanceof BuildValidationError) {
|
|
133
|
+
console.error(formatResult(error.validation));
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
default: {
|
|
142
|
+
console.error(`Unknown command "${command ?? ""}". Usage: velora-plugin <validate|build|pack|publish> [dir] [--json] [--out <path>] [--registry <url>] [--token <token>]`);
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Only run the CLI when this file is executed directly (e.g. `node index.js`
|
|
149
|
+
// or the `velora-plugin` bin), not when it's imported as a module — this file
|
|
150
|
+
// is also imported by index.test.ts to reuse `formatResult`, and running
|
|
151
|
+
// `main()` as an import side-effect would parse the test runner's real
|
|
152
|
+
// process.argv, print a spurious "Unknown command" error, and set
|
|
153
|
+
// process.exitCode as an unrelated side effect of importing a library export.
|
|
154
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
155
|
+
main().catch((error) => {
|
|
156
|
+
console.error(error);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ValidatePluginManifestResult } from "@velora-cms/plugin-sdk/manifest";
|
|
2
|
+
/**
|
|
3
|
+
* Every plugin template (create-velora-plugin) and the google-maps
|
|
4
|
+
* reference plugin expose their manifest from src/plugin.ts or
|
|
5
|
+
* src/plugin.tsx — the same file each vite.config.ts names under
|
|
6
|
+
* federation's exposes['./plugin'].
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveManifestEntry(pluginDir: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Loads a plugin's manifest module IN-PROCESS via tsx's tsImport — not a
|
|
11
|
+
* subprocess — so the real manifest object (with live function references
|
|
12
|
+
* DataTypePluginSchema's z.custom() checks need) never has to cross a
|
|
13
|
+
* serialization boundary. Works uniformly whether the plugin's own
|
|
14
|
+
* tsconfig emits JS (plugins/google-maps) or is a type-check-only gate
|
|
15
|
+
* (create-velora-plugin's scaffolded projects, noEmit: true) — neither's
|
|
16
|
+
* dist/ layout matters, since this never touches dist/.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadManifest(pluginDir: string): Promise<ValidatePluginManifestResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { tsImport } from "tsx/esm/api";
|
|
5
|
+
import { validatePluginManifest } from "@velora-cms/plugin-sdk/manifest";
|
|
6
|
+
/**
|
|
7
|
+
* Every plugin template (create-velora-plugin) and the google-maps
|
|
8
|
+
* reference plugin expose their manifest from src/plugin.ts or
|
|
9
|
+
* src/plugin.tsx — the same file each vite.config.ts names under
|
|
10
|
+
* federation's exposes['./plugin'].
|
|
11
|
+
*/
|
|
12
|
+
export function resolveManifestEntry(pluginDir) {
|
|
13
|
+
const tsEntry = join(pluginDir, "src", "plugin.ts");
|
|
14
|
+
if (existsSync(tsEntry))
|
|
15
|
+
return tsEntry;
|
|
16
|
+
const tsxEntry = join(pluginDir, "src", "plugin.tsx");
|
|
17
|
+
if (existsSync(tsxEntry))
|
|
18
|
+
return tsxEntry;
|
|
19
|
+
throw new Error(`No plugin entry found at src/plugin.ts or src/plugin.tsx in ${pluginDir}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Loads a plugin's manifest module IN-PROCESS via tsx's tsImport — not a
|
|
23
|
+
* subprocess — so the real manifest object (with live function references
|
|
24
|
+
* DataTypePluginSchema's z.custom() checks need) never has to cross a
|
|
25
|
+
* serialization boundary. Works uniformly whether the plugin's own
|
|
26
|
+
* tsconfig emits JS (plugins/google-maps) or is a type-check-only gate
|
|
27
|
+
* (create-velora-plugin's scaffolded projects, noEmit: true) — neither's
|
|
28
|
+
* dist/ layout matters, since this never touches dist/.
|
|
29
|
+
*/
|
|
30
|
+
export async function loadManifest(pluginDir) {
|
|
31
|
+
let entryPath;
|
|
32
|
+
try {
|
|
33
|
+
entryPath = resolveManifestEntry(pluginDir);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return { ok: false, errors: [error instanceof Error ? error.message : String(error)] };
|
|
37
|
+
}
|
|
38
|
+
let mod;
|
|
39
|
+
try {
|
|
40
|
+
mod = (await tsImport(pathToFileURL(entryPath).href, import.meta.url));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
errors: [`failed to load plugin module ${entryPath}: ${error instanceof Error ? error.message : String(error)}`],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return validatePluginManifest(mod.default);
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CMSPlugin } from "@velora-cms/plugin-sdk/manifest";
|
|
2
|
+
/**
|
|
3
|
+
* Structural check for "this manifest declares a bundledTemplate
|
|
4
|
+
* contribution" — mirrors the compile-time contribution matrix at
|
|
5
|
+
* runtime, the same way validate.ts's Zod schemas do, without importing
|
|
6
|
+
* any Zod schema here (loadManifest already validated the manifest before
|
|
7
|
+
* build.ts ever calls this).
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasBundledTemplateContribution(manifest: CMSPlugin): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a starter/ directory is LEGAL for this manifest — the check
|
|
12
|
+
* packStarterIfPresent actually gates on. True in two cases:
|
|
13
|
+
* - the manifest declares a bundledTemplate contribution (section/bundle,
|
|
14
|
+
* hasBundledTemplateContribution above), or
|
|
15
|
+
* - the manifest's type is 'template'. Per manifest.ts's
|
|
16
|
+
* TemplatePluginContributions doc comment: "A template plugin IS a
|
|
17
|
+
* bundled template (per the matrix), so there is no separate
|
|
18
|
+
* bundledTemplate key — it contributes the document types its starter
|
|
19
|
+
* needs." A template plugin is implicitly starter-bearing by design,
|
|
20
|
+
* not by an explicit contribution flag, so it can never fail the
|
|
21
|
+
* 'declares bundledTemplate' structural check the way section/bundle
|
|
22
|
+
* manifests do — it's legal unconditionally.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isStarterBearing(manifest: CMSPlugin): boolean;
|
|
25
|
+
export declare class UndeclaredStarterError extends Error {
|
|
26
|
+
constructor(pluginDir: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Packs <pluginDir>/starter/ into <outDir>/starter.tar.gz when present —
|
|
30
|
+
* the S148 delivery mechanism (the archive rides inside the plugin
|
|
31
|
+
* artifact; packPlugin tars the whole outDir, so nothing else has to
|
|
32
|
+
* change for it to reach the marketplace).
|
|
33
|
+
*
|
|
34
|
+
* Rules (S148 Global Constraints):
|
|
35
|
+
* - starter/ absent → no-op, regardless of whether bundledTemplate is
|
|
36
|
+
* declared (a bundledTemplate without an archive stays legal —
|
|
37
|
+
* repository-only or metadata-only).
|
|
38
|
+
* - starter/ present, NOT starter-bearing (see isStarterBearing) → throws
|
|
39
|
+
* UndeclaredStarterError. An undeclared starter/ is a mistake, not a
|
|
40
|
+
* choice.
|
|
41
|
+
* - starter/ present and starter-bearing (bundledTemplate declared, OR
|
|
42
|
+
* type 'template') → archive created, entries relative to starter/,
|
|
43
|
+
* excluding node_modules/dist/.git/.next/.astro and env files
|
|
44
|
+
* (.env, .env.* — except .env.example) at any depth.
|
|
45
|
+
*
|
|
46
|
+
* Returns the archive path, or null when no archive was created.
|
|
47
|
+
*/
|
|
48
|
+
export declare function packStarterIfPresent(pluginDir: string, outDir: string, manifest: CMSPlugin): Promise<string | null>;
|