@droposs/plugin-cli 0.1.0 → 0.2.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.
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { signPlugin, packPlugin } from "../dist/signer.js";
2
+ import {
3
+ signPlugin,
4
+ packPlugin,
5
+ buildPlugin,
6
+ testPlugin,
7
+ initPlugin,
8
+ validateManifest,
9
+ } from "../dist/index.js";
10
+ import { readFile } from "node:fs/promises";
11
+ import path from "node:path";
3
12
 
4
13
  const command = process.argv[2];
5
14
  const args = process.argv.slice(3);
@@ -9,26 +18,72 @@ async function main() {
9
18
  case "sign": {
10
19
  const dir = args[0] || ".";
11
20
  const res = await signPlugin(dir);
12
- console.log(`Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})`);
21
+ console.log(
22
+ `Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})`,
23
+ );
13
24
  break;
14
25
  }
15
26
  case "pack": {
16
27
  const dir = args[0] || ".";
17
28
  const outDir = args[1];
18
29
  const res = await packPlugin(dir, outDir);
19
- console.log(`Packed plugin '${res.id}' v${res.version} to ${res.packagePath}`);
30
+ console.log(
31
+ `Packed plugin '${res.id}' v${res.version} to ${res.packagePath}`,
32
+ );
33
+ break;
34
+ }
35
+ case "build": {
36
+ const dir = args[0] || ".";
37
+ const res = await buildPlugin(dir);
38
+ console.log(
39
+ `Built plugin at ${dir} (server: ${res.serverBuilt ? "yes" : "no"}, client: ${res.clientBuilt ? "yes" : "no"})`,
40
+ );
41
+ break;
42
+ }
43
+ case "test": {
44
+ const dir = args[0] || ".";
45
+ await testPlugin(dir);
46
+ break;
47
+ }
48
+ case "init": {
49
+ const dir = args[0] || "my-drop-plugin";
50
+ const res = await initPlugin(dir);
51
+ console.log(`Initialized new Drop plugin '${res.id}' at ${res.targetPath}`);
52
+ break;
53
+ }
54
+ case "validate": {
55
+ const dir = args[0] || ".";
56
+ const manifestPath = path.join(path.resolve(process.cwd(), dir), "drop-plugin.json");
57
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
58
+ const validation = await validateManifest(manifest);
59
+ if (!validation.valid) {
60
+ console.error("Validation failed:");
61
+ for (const err of validation.errors) {
62
+ console.error(` - ${err}`);
63
+ }
64
+ process.exit(1);
65
+ } else {
66
+ console.log(`Manifest at ${manifestPath} is valid.`);
67
+ }
20
68
  break;
21
69
  }
22
70
  case "help":
71
+ case "--help":
72
+ case "-h":
23
73
  default:
24
74
  console.log(`Drop Plugin CLI (drop-plugin)
25
75
 
26
76
  Usage:
77
+ drop-plugin init [dir] Initialize a new plugin from starter template
78
+ drop-plugin build [dir] Bundle server/client entry points with esbuild and sign
27
79
  drop-plugin sign [dir] Calculate SHA-256 digests and sign drop-plugin.json
80
+ drop-plugin validate [dir] Validate drop-plugin.json against official schema
81
+ drop-plugin test [dir] Run plugin tests with Node test runner
28
82
  drop-plugin pack [dir] [out] Verify, sign, and package bundle into .dropplugin archive
29
- drop-plugin build Compile plugin bundle
30
- drop-plugin test Run plugin tests
31
83
  `);
84
+ if (command && command !== "help" && command !== "--help" && command !== "-h") {
85
+ process.exit(1);
86
+ }
32
87
  break;
33
88
  }
34
89
  }
@@ -0,0 +1,10 @@
1
+ export interface BuildOptions {
2
+ minify?: boolean;
3
+ sourcemap?: boolean;
4
+ sign?: boolean;
5
+ signingKey?: string;
6
+ }
7
+ export declare function buildPlugin(targetDir?: string, options?: BuildOptions): Promise<{
8
+ serverBuilt: boolean;
9
+ clientBuilt: boolean;
10
+ }>;
@@ -0,0 +1,84 @@
1
+ import * as esbuild from "esbuild";
2
+ import path from "node:path";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { signPlugin } from "./signer.js";
5
+ export async function buildPlugin(targetDir = ".", options = {}) {
6
+ const dir = path.resolve(process.cwd(), targetDir);
7
+ const manifestPath = path.join(dir, "drop-plugin.json");
8
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
9
+ let serverBuilt = false;
10
+ let clientBuilt = false;
11
+ // 1. Build Server Entry if source exists
12
+ const serverSourceCandidates = [
13
+ manifest.server?.source,
14
+ "src/index.ts",
15
+ "src/index.js",
16
+ "src/server.ts",
17
+ ].filter(Boolean);
18
+ let serverEntrySource = null;
19
+ for (const candidate of serverSourceCandidates) {
20
+ const p = path.join(dir, candidate);
21
+ if (await stat(p).catch(() => null)) {
22
+ serverEntrySource = p;
23
+ break;
24
+ }
25
+ }
26
+ const serverOutFile = path.resolve(dir, manifest.server?.entry ?? manifest.entry ?? "dist/src/index.js");
27
+ if (serverEntrySource) {
28
+ await esbuild.build({
29
+ entryPoints: [serverEntrySource],
30
+ outfile: serverOutFile,
31
+ bundle: true,
32
+ platform: "node",
33
+ target: "node22",
34
+ format: "esm",
35
+ sourcemap: options.sourcemap ?? true,
36
+ minify: options.minify ?? false,
37
+ external: [
38
+ "@droposs/plugin-sdk",
39
+ "@drop/plugin-sdk",
40
+ "h3",
41
+ "pino",
42
+ ],
43
+ });
44
+ serverBuilt = true;
45
+ }
46
+ // 2. Build Client Entry if source exists
47
+ const clientSourceCandidates = [
48
+ manifest.client?.source,
49
+ "src/client.ts",
50
+ "src/client.js",
51
+ ].filter(Boolean);
52
+ let clientEntrySource = null;
53
+ for (const candidate of clientSourceCandidates) {
54
+ const p = path.join(dir, candidate);
55
+ if (await stat(p).catch(() => null)) {
56
+ clientEntrySource = p;
57
+ break;
58
+ }
59
+ }
60
+ const clientOutFile = path.resolve(dir, manifest.client?.entry ?? manifest.clientEntry ?? "dist/src/client.js");
61
+ if (clientEntrySource) {
62
+ await esbuild.build({
63
+ entryPoints: [clientEntrySource],
64
+ outfile: clientOutFile,
65
+ bundle: true,
66
+ platform: "browser",
67
+ target: "es2022",
68
+ format: "esm",
69
+ sourcemap: options.sourcemap ?? true,
70
+ minify: options.minify ?? false,
71
+ external: [
72
+ "vue",
73
+ "@droposs/plugin-sdk",
74
+ "@drop/plugin-sdk",
75
+ ],
76
+ });
77
+ clientBuilt = true;
78
+ }
79
+ // 3. Automatically re-sign the plugin bundle after building
80
+ if (options.sign !== false) {
81
+ await signPlugin(dir, options.signingKey);
82
+ }
83
+ return { serverBuilt, clientBuilt };
84
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./signer.js";
2
+ export * from "./builder.js";
3
+ export * from "./testRunner.js";
4
+ export * from "./scaffolder.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./signer.js";
2
+ export * from "./builder.js";
3
+ export * from "./testRunner.js";
4
+ export * from "./scaffolder.js";
@@ -0,0 +1,9 @@
1
+ export interface InitOptions {
2
+ id?: string;
3
+ name?: string;
4
+ author?: string;
5
+ }
6
+ export declare function initPlugin(targetDir: string, options?: InitOptions): Promise<{
7
+ targetPath: string;
8
+ id: string;
9
+ }>;
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ import { cp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
3
+ export async function initPlugin(targetDir, options = {}) {
4
+ const targetPath = path.resolve(process.cwd(), targetDir);
5
+ await mkdir(targetPath, { recursive: true });
6
+ const candidates = [
7
+ path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../../templates/starter-plugin"),
8
+ path.resolve(path.dirname(new URL(import.meta.url).pathname), "../templates/starter-plugin"),
9
+ path.resolve(process.cwd(), "templates/starter-plugin"),
10
+ ];
11
+ let templateDir = null;
12
+ for (const cand of candidates) {
13
+ if (await stat(cand).catch(() => null)) {
14
+ templateDir = cand;
15
+ break;
16
+ }
17
+ }
18
+ if (!templateDir) {
19
+ throw new Error("Starter plugin template directory not found");
20
+ }
21
+ await cp(templateDir, targetPath, {
22
+ recursive: true,
23
+ filter: (src) => {
24
+ const basename = path.basename(src);
25
+ return basename !== "node_modules" && basename !== "dist";
26
+ },
27
+ });
28
+ const pluginId = options.id ||
29
+ path
30
+ .basename(targetPath)
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9._-]/g, "-");
33
+ const pluginName = options.name || pluginId;
34
+ // Update drop-plugin.json
35
+ const manifestPath = path.join(targetPath, "drop-plugin.json");
36
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
37
+ manifest.id = pluginId;
38
+ manifest.name = pluginName;
39
+ if (options.author)
40
+ manifest.author = options.author;
41
+ delete manifest.checksum;
42
+ delete manifest.files;
43
+ delete manifest.signature;
44
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
45
+ // Update package.json
46
+ const pkgPath = path.join(targetPath, "package.json");
47
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
48
+ pkg.name = `drop-${pluginId}`;
49
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
50
+ return { targetPath, id: pluginId };
51
+ }
package/dist/signer.d.ts CHANGED
@@ -1,5 +1,10 @@
1
+ export declare function isInside(base: string, candidate: string): boolean;
2
+ export declare function validateManifest(manifest: unknown): Promise<{
3
+ valid: boolean;
4
+ errors: string[];
5
+ }>;
1
6
  export declare function listFiles(root: string, prefix?: string): Promise<string[]>;
2
- export declare function signPlugin(targetDir: string, signingKey?: string): Promise<{
7
+ export declare function signPlugin(targetDir: string, signingKey?: string, validate?: boolean): Promise<{
3
8
  fileCount: number;
4
9
  signed: boolean;
5
10
  }>;
package/dist/signer.js CHANGED
@@ -1,22 +1,66 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
2
  import { readdir, readFile, realpath, stat, writeFile, mkdir } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
3
4
  import path from "node:path";
5
+ import Ajv from "ajv";
4
6
  const MANIFEST_FILE = "drop-plugin.json";
5
- function isInside(base, candidate) {
6
- const relative = path.relative(base, candidate);
7
- return (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative));
7
+ export function isInside(base, candidate) {
8
+ const rel = path.relative(path.resolve(base), path.resolve(candidate));
9
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
10
+ }
11
+ async function loadSchema() {
12
+ try {
13
+ const require = createRequire(import.meta.url);
14
+ const schemaPath = require.resolve("@droposs/plugin-sdk/schema.json");
15
+ return JSON.parse(await readFile(schemaPath, "utf-8"));
16
+ }
17
+ catch {
18
+ const fallback = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../plugin-sdk/schema/drop-plugin.schema.json");
19
+ return JSON.parse(await readFile(fallback, "utf-8"));
20
+ }
21
+ }
22
+ let cachedValidator = null;
23
+ let cachedErrors = [];
24
+ export async function validateManifest(manifest) {
25
+ if (!cachedValidator) {
26
+ const schema = await loadSchema();
27
+ // @ts-ignore
28
+ const AjvClass = Ajv.default ?? Ajv;
29
+ const ajv = new AjvClass({ allErrors: true, strict: false });
30
+ const compiled = ajv.compile(schema);
31
+ cachedValidator = (data) => {
32
+ const ok = compiled(data);
33
+ if (!ok && compiled.errors) {
34
+ cachedErrors = compiled.errors.map((err) => `${err.instancePath || "/"} ${err.message}`);
35
+ }
36
+ else {
37
+ cachedErrors = [];
38
+ }
39
+ return Boolean(ok);
40
+ };
41
+ }
42
+ const valid = cachedValidator(manifest);
43
+ return { valid, errors: [...cachedErrors] };
8
44
  }
9
45
  export async function listFiles(root, prefix = "") {
10
46
  const results = [];
11
- const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
47
+ const dirPath = path.join(root, prefix);
48
+ const entries = await readdir(dirPath, { withFileTypes: true });
12
49
  for (const entry of entries) {
13
50
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
14
- if (entry.isDirectory()) {
51
+ const fullPath = path.join(root, rel);
52
+ // Enforce confinement invariant: symlinks or traversal outside bundle root are rejected
53
+ const real = await realpath(fullPath).catch(() => null);
54
+ if (!real || !isInside(root, real)) {
55
+ throw new Error(`Path traversal or symlink escape detected: ${rel}`);
56
+ }
57
+ const s = await stat(real);
58
+ if (s.isDirectory()) {
15
59
  if (entry.name === "node_modules" || entry.name === ".git")
16
60
  continue;
17
61
  results.push(...(await listFiles(root, rel)));
18
62
  }
19
- else if (entry.isFile()) {
63
+ else if (s.isFile()) {
20
64
  if (!prefix && entry.name === MANIFEST_FILE)
21
65
  continue;
22
66
  results.push(rel);
@@ -24,7 +68,7 @@ export async function listFiles(root, prefix = "") {
24
68
  }
25
69
  return results.sort((a, b) => a.localeCompare(b));
26
70
  }
27
- export async function signPlugin(targetDir, signingKey) {
71
+ export async function signPlugin(targetDir, signingKey, validate = true) {
28
72
  const resolvedPath = path.resolve(process.cwd(), targetDir);
29
73
  const bundleDir = await realpath(resolvedPath).catch(() => null);
30
74
  if (!bundleDir) {
@@ -32,12 +76,21 @@ export async function signPlugin(targetDir, signingKey) {
32
76
  }
33
77
  const manifestPath = path.join(bundleDir, MANIFEST_FILE);
34
78
  const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
79
+ if (validate) {
80
+ const validation = await validateManifest(manifest);
81
+ if (!validation.valid) {
82
+ throw new Error(`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`);
83
+ }
84
+ }
35
85
  // Identify primary entry (v1 or v2 server/client entry)
36
86
  const entry = manifest.entry ??
37
87
  manifest.server?.entry ??
38
88
  manifest.client?.entry ??
39
89
  "index.js";
40
90
  const entryPath = path.resolve(bundleDir, entry);
91
+ if (!isInside(bundleDir, entryPath) && entryPath !== bundleDir) {
92
+ throw new Error(`Entry path outside bundle directory: ${entry}`);
93
+ }
41
94
  const entryExists = await stat(entryPath).catch(() => null);
42
95
  if (entryExists) {
43
96
  const entryBytes = await readFile(entryPath);
@@ -0,0 +1 @@
1
+ export declare function testPlugin(targetDir?: string): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { readdir, stat } from "node:fs/promises";
4
+ export async function testPlugin(targetDir = ".") {
5
+ const dir = path.resolve(process.cwd(), targetDir);
6
+ const candidates = [
7
+ path.join(dir, "dist", "test"),
8
+ path.join(dir, "test"),
9
+ ];
10
+ let testFiles = [];
11
+ for (const cand of candidates) {
12
+ if (await stat(cand).catch(() => null)) {
13
+ const files = await readdir(cand, { recursive: true });
14
+ for (const f of files) {
15
+ if (typeof f === "string" &&
16
+ (f.endsWith(".test.js") || f.endsWith(".spec.js"))) {
17
+ testFiles.push(path.join(cand, f));
18
+ }
19
+ }
20
+ if (testFiles.length > 0)
21
+ break;
22
+ }
23
+ }
24
+ if (testFiles.length === 0) {
25
+ const testDir = path.join(dir, "test");
26
+ if (await stat(testDir).catch(() => null)) {
27
+ const files = await readdir(testDir, { recursive: true });
28
+ testFiles = files
29
+ .filter((f) => typeof f === "string" &&
30
+ (f.endsWith(".test.ts") || f.endsWith(".spec.ts")))
31
+ .map((f) => path.join(testDir, f));
32
+ }
33
+ }
34
+ if (testFiles.length === 0) {
35
+ console.log(`No test files found in ${targetDir}`);
36
+ return;
37
+ }
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(process.execPath, ["--test", ...testFiles], {
40
+ cwd: dir,
41
+ stdio: "inherit",
42
+ });
43
+ child.on("close", (code) => {
44
+ if (code === 0) {
45
+ resolve();
46
+ }
47
+ else {
48
+ reject(new Error(`Tests exited with code ${code}`));
49
+ }
50
+ });
51
+ });
52
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@droposs/plugin-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Drop Plugin build, test, signing, and packaging CLI for Drop OSS",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "drop-plugin": "./bin/drop-plugin.js"
8
8
  },
9
- "main": "dist/signer.js",
10
- "types": "dist/signer.d.ts",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
11
  "files": [
12
12
  "bin",
13
13
  "dist"
@@ -35,10 +35,15 @@
35
35
  "playnite"
36
36
  ],
37
37
  "devDependencies": {
38
- "typescript": "^5.7.0",
39
- "@types/node": "^22.0.0"
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.7.0"
40
40
  },
41
41
  "license": "MIT",
42
+ "dependencies": {
43
+ "@droposs/plugin-sdk": "0.2.0",
44
+ "ajv": "^8.20.0",
45
+ "esbuild": "^0.28.2"
46
+ },
42
47
  "scripts": {
43
48
  "build": "tsc",
44
49
  "test": "node --test"