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.
Files changed (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +22 -0
  3. package/dist/build-federation-bundle.d.ts +29 -0
  4. package/dist/build-federation-bundle.js +93 -0
  5. package/dist/build.d.ts +41 -0
  6. package/dist/build.js +123 -0
  7. package/dist/bundle-server-manifest.d.ts +27 -0
  8. package/dist/bundle-server-manifest.js +93 -0
  9. package/dist/checks/audit.d.ts +2 -0
  10. package/dist/checks/audit.js +50 -0
  11. package/dist/checks/bundle-size.d.ts +3 -0
  12. package/dist/checks/bundle-size.js +21 -0
  13. package/dist/checks/db-compat.d.ts +3 -0
  14. package/dist/checks/db-compat.js +15 -0
  15. package/dist/checks/manifest.d.ts +3 -0
  16. package/dist/checks/manifest.js +12 -0
  17. package/dist/checks/shared-deps.d.ts +3 -0
  18. package/dist/checks/shared-deps.js +31 -0
  19. package/dist/checks/typescript.d.ts +2 -0
  20. package/dist/checks/typescript.js +34 -0
  21. package/dist/checks/views.d.ts +3 -0
  22. package/dist/checks/views.js +63 -0
  23. package/dist/copy-sample-content.d.ts +39 -0
  24. package/dist/copy-sample-content.js +57 -0
  25. package/dist/index.d.ts +12 -0
  26. package/dist/index.js +159 -0
  27. package/dist/load-manifest.d.ts +18 -0
  28. package/dist/load-manifest.js +49 -0
  29. package/dist/pack-starter.d.ts +48 -0
  30. package/dist/pack-starter.js +118 -0
  31. package/dist/pack.d.ts +12 -0
  32. package/dist/pack.js +26 -0
  33. package/dist/publish.d.ts +24 -0
  34. package/dist/publish.js +48 -0
  35. package/dist/types.d.ts +11 -0
  36. package/dist/types.js +1 -0
  37. package/dist/validate.d.ts +5 -0
  38. package/dist/validate.js +49 -0
  39. package/dist/write-manifest-json.d.ts +7 -0
  40. package/dist/write-manifest-json.js +72 -0
  41. 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,3 @@
1
+ import type { FederationBuildResult } from "../build-federation-bundle.js";
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function checkBundleSize(build: FederationBuildResult): CheckResult;
@@ -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,3 @@
1
+ import type { ValidatePluginManifestResult } from "@velora-cms/plugin-sdk/manifest";
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function checkDatabaseCompatibility(result: ValidatePluginManifestResult): CheckResult;
@@ -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,3 @@
1
+ import type { ValidatePluginManifestResult } from "@velora-cms/plugin-sdk/manifest";
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function checkManifest(result: ValidatePluginManifestResult): CheckResult;
@@ -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,3 @@
1
+ import type { FederationBuildResult } from "../build-federation-bundle.js";
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function checkSharedDeps(pluginDir: string, build: FederationBuildResult): CheckResult;
@@ -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,2 @@
1
+ import type { CheckResult } from "../types.js";
2
+ export declare function checkTypeScript(pluginDir: string): CheckResult;
@@ -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,3 @@
1
+ import type { ValidatePluginManifestResult } from "@velora-cms/plugin-sdk/manifest";
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function checkRequiredViews(result: ValidatePluginManifestResult): CheckResult;
@@ -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
+ }
@@ -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>;