@abraca/plugin-cli 2.3.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `abra-plugin pack [path]` — recompute the manifest's `integrity` field
3
+ * (SHA-256 of the entry bundle) and write the updated manifest back to disk.
4
+ *
5
+ * Authors run this after every bundle rebuild — the registry server refuses
6
+ * to accept a manifest whose `integrity` doesn't match the artifact, so
7
+ * having it as a one-shot CLI step removes a class of "I forgot to update
8
+ * the hash" submission rejections.
9
+ *
10
+ * The command also validates the manifest after recomputing, exiting
11
+ * non-zero if validation still fails (e.g. unknown capability declared).
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join, relative } from "node:path";
16
+ import { validatePluginManifest } from "@abraca/schema";
17
+ import { ansi, isFile, readJsonFile, resolveCwd, sha256OfFile } from "../io.ts";
18
+
19
+ export interface PackOptions {
20
+ /** Path to the manifest file. Defaults to `./manifest.json`. */
21
+ path?: string;
22
+ /** If true, don't write the file — just print the new hash. */
23
+ dryRun?: boolean;
24
+ }
25
+
26
+ export interface PackResult {
27
+ exitCode: 0 | 1 | 2;
28
+ /** The newly-computed integrity hash, or `null` on early failure. */
29
+ integrity: string | null;
30
+ /** Whether the on-disk manifest was rewritten. */
31
+ rewrote: boolean;
32
+ }
33
+
34
+ export function pack(opts: PackOptions = {}): PackResult {
35
+ const rel = opts.path ?? "manifest.json";
36
+ const abs = resolveCwd(rel);
37
+
38
+ if (!isFile(abs)) {
39
+ console.error(ansi.red(`error: ${rel}: file not found`));
40
+ return { exitCode: 2, integrity: null, rewrote: false };
41
+ }
42
+
43
+ let parsed: Record<string, unknown>;
44
+ try {
45
+ const value = readJsonFile(abs);
46
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
47
+ throw new Error("manifest must be a JSON object");
48
+ }
49
+ parsed = value as Record<string, unknown>;
50
+ } catch (err) {
51
+ console.error(ansi.red(`error: ${(err as Error).message}`));
52
+ return { exitCode: 2, integrity: null, rewrote: false };
53
+ }
54
+
55
+ const entry = parsed.entry;
56
+ if (typeof entry !== "string" || entry.length === 0) {
57
+ console.error(
58
+ ansi.red("error: manifest is missing an 'entry' field (path to bundle)"),
59
+ );
60
+ return { exitCode: 2, integrity: null, rewrote: false };
61
+ }
62
+
63
+ const entryAbs = join(dirname(abs), entry);
64
+ if (!isFile(entryAbs)) {
65
+ console.error(
66
+ ansi.red(`error: entry bundle not found: ${relative(process.cwd(), entryAbs)}`),
67
+ );
68
+ return { exitCode: 2, integrity: null, rewrote: false };
69
+ }
70
+
71
+ const integrity = sha256OfFile(entryAbs);
72
+ const prev = parsed.integrity;
73
+ parsed.integrity = integrity;
74
+
75
+ const newJson = `${JSON.stringify(parsed, null, 2)}\n`;
76
+ const oldJson = readFileSync(abs, "utf8");
77
+
78
+ if (opts.dryRun) {
79
+ console.log(`${ansi.cyan("dry-run")}: would write integrity=${integrity}`);
80
+ // Still run validation against the in-memory manifest so the author
81
+ // sees issues even without writing.
82
+ const v = validatePluginManifest(parsed);
83
+ if (!v.ok) {
84
+ console.error(ansi.red(`✗ post-pack validation failed`));
85
+ for (const issue of v.errors) {
86
+ const where = issue.path.length > 0 ? issue.path.map(String).join(".") : "<root>";
87
+ console.error(` ${ansi.yellow(where)}: ${issue.message}`);
88
+ }
89
+ return { exitCode: 1, integrity, rewrote: false };
90
+ }
91
+ return { exitCode: 0, integrity, rewrote: false };
92
+ }
93
+
94
+ const wrote = newJson !== oldJson;
95
+ if (wrote) {
96
+ writeFileSync(abs, newJson, "utf8");
97
+ }
98
+
99
+ const action = wrote
100
+ ? prev === integrity
101
+ ? "reformatted"
102
+ : `updated (${ansi.dim(String(prev ?? "<unset>"))} → ${integrity})`
103
+ : "unchanged";
104
+ console.log(`${ansi.green("✓")} ${rel}: ${action}`);
105
+
106
+ // Final validation pass — pack succeeds even if the rewrite was clean
107
+ // only when the manifest as a whole still validates.
108
+ const v = validatePluginManifest(parsed);
109
+ if (!v.ok) {
110
+ console.error(
111
+ ansi.red(`✗ but manifest fails validation — fix and re-run`),
112
+ );
113
+ for (const issue of v.errors) {
114
+ const where = issue.path.length > 0 ? issue.path.map(String).join(".") : "<root>";
115
+ console.error(` ${ansi.yellow(where)}: ${issue.message}`);
116
+ }
117
+ return { exitCode: 1, integrity, rewrote: wrote };
118
+ }
119
+
120
+ return { exitCode: 0, integrity, rewrote: wrote };
121
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `abra-plugin preview-scan [path]` — validate the manifest, then run a
3
+ * quick static check on the bundle:
4
+ *
5
+ * - **capability/code mismatch**: if the bundle uses `fetch` / `XHR` /
6
+ * `WebSocket` but the manifest doesn't declare `network[:*]`, flag it.
7
+ * Symmetric: if `network:*` is declared but the bundle has no network
8
+ * calls, warn (declared-but-unused).
9
+ * - **declared `contributes` sanity**: every name in `manifest.contributes`
10
+ * should appear somewhere in the bundle string. Cheap heuristic — a
11
+ * proper AST walk lives on the registry server in Phase H.
12
+ *
13
+ * Designed to mirror what the registry's automated scanner does on
14
+ * submission, so authors catch issues locally.
15
+ *
16
+ * Exit code 0 = clean, 1 = warnings only, 2 = errors. Authors should
17
+ * resolve all errors before submitting; warnings are advisory.
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { dirname, join, relative } from "node:path";
22
+ import { validatePluginManifest } from "@abraca/schema";
23
+ import type { PluginManifest } from "@abraca/plugin";
24
+ import { ansi, isFile, readJsonFile, resolveCwd } from "../io.ts";
25
+
26
+ export interface PreviewScanOptions {
27
+ path?: string;
28
+ }
29
+
30
+ export interface PreviewScanFinding {
31
+ severity: "error" | "warning";
32
+ rule: string;
33
+ message: string;
34
+ }
35
+
36
+ export interface PreviewScanResult {
37
+ exitCode: 0 | 1 | 2;
38
+ findings: ReadonlyArray<PreviewScanFinding>;
39
+ }
40
+
41
+ const NETWORK_API_RE = /\b(fetch|XMLHttpRequest|WebSocket|EventSource)\b/;
42
+ const CLIPBOARD_READ_RE = /navigator\.clipboard\.read(?!Text)?Text?\b/;
43
+ const CLIPBOARD_WRITE_RE = /navigator\.clipboard\.write/;
44
+
45
+ function declaresCap(
46
+ manifest: PluginManifest,
47
+ predicate: (cap: string) => boolean,
48
+ ): boolean {
49
+ const all = [
50
+ ...(manifest.capabilities.required ?? []),
51
+ ...(manifest.capabilities.optional ?? []),
52
+ ];
53
+ return all.some(predicate);
54
+ }
55
+
56
+ export function previewScan(opts: PreviewScanOptions = {}): PreviewScanResult {
57
+ const rel = opts.path ?? "manifest.json";
58
+ const abs = resolveCwd(rel);
59
+ const findings: PreviewScanFinding[] = [];
60
+
61
+ if (!isFile(abs)) {
62
+ console.error(ansi.red(`error: ${rel}: file not found`));
63
+ return { exitCode: 2, findings };
64
+ }
65
+
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = readJsonFile(abs);
69
+ } catch (err) {
70
+ console.error(ansi.red(`error: ${(err as Error).message}`));
71
+ return { exitCode: 2, findings };
72
+ }
73
+
74
+ const result = validatePluginManifest(parsed);
75
+ if (!result.ok) {
76
+ console.error(ansi.red(`✗ manifest validation failed`));
77
+ for (const issue of result.errors) {
78
+ const where = issue.path.length > 0 ? issue.path.map(String).join(".") : "<root>";
79
+ console.error(` ${ansi.yellow(where)}: ${issue.message}`);
80
+ }
81
+ return { exitCode: 2, findings };
82
+ }
83
+
84
+ const manifest = result.value;
85
+ const entryAbs = join(dirname(abs), manifest.entry);
86
+
87
+ if (!isFile(entryAbs)) {
88
+ findings.push({
89
+ severity: "error",
90
+ rule: "missing-entry",
91
+ message: `entry bundle not found: ${relative(process.cwd(), entryAbs)}`,
92
+ });
93
+ printAndExit(findings);
94
+ return { exitCode: 2, findings };
95
+ }
96
+
97
+ const source = readFileSync(entryAbs, "utf8");
98
+
99
+ // Capability/code mismatch checks
100
+ const hasNetCalls = NETWORK_API_RE.test(source);
101
+ const hasNetCap = declaresCap(manifest, (c) => c === "network" || c.startsWith("network:"));
102
+ if (hasNetCalls && !hasNetCap) {
103
+ findings.push({
104
+ severity: "error",
105
+ rule: "undeclared-network",
106
+ message:
107
+ "bundle uses fetch/XHR/WebSocket but no network[:*] capability is declared — add it to capabilities.required or capabilities.optional",
108
+ });
109
+ }
110
+ if (hasNetCap && !hasNetCalls) {
111
+ findings.push({
112
+ severity: "warning",
113
+ rule: "unused-network",
114
+ message:
115
+ "network[:*] capability is declared but no fetch/XHR/WebSocket usage was found — remove the capability if unused",
116
+ });
117
+ }
118
+
119
+ const hasClipRead = CLIPBOARD_READ_RE.test(source);
120
+ const hasClipReadCap = declaresCap(manifest, (c) => c === "clipboard:read");
121
+ if (hasClipRead && !hasClipReadCap) {
122
+ findings.push({
123
+ severity: "error",
124
+ rule: "undeclared-clipboard-read",
125
+ message: "bundle reads the clipboard but clipboard:read is not declared",
126
+ });
127
+ }
128
+
129
+ const hasClipWrite = CLIPBOARD_WRITE_RE.test(source);
130
+ const hasClipWriteCap = declaresCap(manifest, (c) => c === "clipboard:write");
131
+ if (hasClipWrite && !hasClipWriteCap) {
132
+ findings.push({
133
+ severity: "error",
134
+ rule: "undeclared-clipboard-write",
135
+ message: "bundle writes the clipboard but clipboard:write is not declared",
136
+ });
137
+ }
138
+
139
+ // Declared `contributes` sanity — every name should appear in the bundle.
140
+ for (const [field, names] of Object.entries(manifest.contributes ?? {})) {
141
+ const list = (names ?? []) as readonly string[];
142
+ for (const name of list) {
143
+ if (name.length < 3) continue; // skip short noise
144
+ if (!source.includes(name)) {
145
+ findings.push({
146
+ severity: "warning",
147
+ rule: "contributes-not-in-bundle",
148
+ message: `'${name}' declared in contributes.${field} but not referenced in entry bundle — possible drift`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+
154
+ printAndExit(findings);
155
+ return { exitCode: severityExitCode(findings), findings };
156
+ }
157
+
158
+ function severityExitCode(
159
+ findings: ReadonlyArray<PreviewScanFinding>,
160
+ ): 0 | 1 | 2 {
161
+ if (findings.some((f) => f.severity === "error")) return 2;
162
+ if (findings.length > 0) return 1;
163
+ return 0;
164
+ }
165
+
166
+ function printAndExit(findings: ReadonlyArray<PreviewScanFinding>): void {
167
+ if (findings.length === 0) {
168
+ console.log(`${ansi.green("✓")} clean — manifest + bundle pass preview scan`);
169
+ return;
170
+ }
171
+ for (const f of findings) {
172
+ const badge =
173
+ f.severity === "error" ? ansi.red("error") : ansi.yellow("warning");
174
+ console.error(`${badge} ${ansi.dim(`[${f.rule}]`)} ${f.message}`);
175
+ }
176
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `abra-plugin validate [path]` — load a manifest.json and validate it
3
+ * against `@abraca/schema`'s `PluginManifestSchema`. Pretty-prints every
4
+ * issue with its JSON path; exit code 0 on success, 1 on validation
5
+ * failure, 2 on read/parse error.
6
+ *
7
+ * Defaults `path` to `./manifest.json` when omitted.
8
+ *
9
+ * This is the same validator the registry server runs on submission, so a
10
+ * green local check is a strong signal the submission will pass static
11
+ * checks too.
12
+ */
13
+
14
+ import { validatePluginManifest } from "@abraca/schema";
15
+ import { ansi, isFile, readJsonFile, resolveCwd } from "../io.ts";
16
+
17
+ export interface ValidateOptions {
18
+ /** Path to the manifest file. Defaults to `./manifest.json`. */
19
+ path?: string;
20
+ /** If true, suppress the success summary line. Errors are still printed. */
21
+ quiet?: boolean;
22
+ }
23
+
24
+ export interface ValidateResult {
25
+ exitCode: 0 | 1 | 2;
26
+ /** Issues found by the schema. Empty on success. */
27
+ issues: ReadonlyArray<{ path: string; message: string; code?: string }>;
28
+ }
29
+
30
+ export function validate(opts: ValidateOptions = {}): ValidateResult {
31
+ const rel = opts.path ?? "manifest.json";
32
+ const abs = resolveCwd(rel);
33
+
34
+ if (!isFile(abs)) {
35
+ console.error(ansi.red(`error: ${rel}: file not found`));
36
+ return { exitCode: 2, issues: [] };
37
+ }
38
+
39
+ let parsed: unknown;
40
+ try {
41
+ parsed = readJsonFile(abs);
42
+ } catch (err) {
43
+ console.error(ansi.red(`error: ${(err as Error).message}`));
44
+ return { exitCode: 2, issues: [] };
45
+ }
46
+
47
+ const result = validatePluginManifest(parsed);
48
+ if (result.ok) {
49
+ if (!opts.quiet) {
50
+ console.log(
51
+ `${ansi.green("✓")} ${rel} ${ansi.dim(`(id=${result.value.id} v${result.value.version})`)}`,
52
+ );
53
+ }
54
+ return { exitCode: 0, issues: [] };
55
+ }
56
+
57
+ const issues = result.errors.map((e) => ({
58
+ path: e.path.map((p) => String(p)).join("."),
59
+ message: e.message,
60
+ code: e.code,
61
+ }));
62
+
63
+ console.error(
64
+ ansi.red(`✗ ${rel}: ${issues.length} issue${issues.length === 1 ? "" : "s"}`),
65
+ );
66
+ for (const issue of issues) {
67
+ const where = issue.path.length > 0 ? issue.path : "<root>";
68
+ console.error(
69
+ ` ${ansi.yellow(where)}: ${issue.message}${issue.code ? ansi.dim(` [${issue.code}]`) : ""}`,
70
+ );
71
+ }
72
+ return { exitCode: 1, issues };
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `abra-plugin` — CLI for Abracadabra plugin authors.
4
+ *
5
+ * abra-plugin validate [path/to/manifest.json]
6
+ * abra-plugin pack [path/to/manifest.json] [--dry-run]
7
+ * abra-plugin preview-scan [path/to/manifest.json]
8
+ *
9
+ * Each command defaults the path to `./manifest.json`. Exit codes:
10
+ * 0 success
11
+ * 1 warnings (preview-scan) or manifest-still-fails-validation (pack)
12
+ * 2 read/parse error or hard validation failure
13
+ *
14
+ * No external dependencies — pulls only `@abraca/schema` (Zod validator)
15
+ * + `@abraca/plugin` (manifest types) which are zero-runtime-cost when
16
+ * imported as `import type`.
17
+ */
18
+
19
+ import { validate } from "./commands/validate.ts";
20
+ import { pack } from "./commands/pack.ts";
21
+ import { previewScan } from "./commands/preview-scan.ts";
22
+ import { ansi } from "./io.ts";
23
+
24
+ // Public surface — small, so library consumers can call commands programmatically.
25
+ export { validate, type ValidateOptions, type ValidateResult } from "./commands/validate.ts";
26
+ export { pack, type PackOptions, type PackResult } from "./commands/pack.ts";
27
+ export {
28
+ previewScan,
29
+ type PreviewScanOptions,
30
+ type PreviewScanResult,
31
+ type PreviewScanFinding,
32
+ } from "./commands/preview-scan.ts";
33
+
34
+ function help(): void {
35
+ const lines = [
36
+ `${ansi.bold("abra-plugin")} — Abracadabra plugin author toolkit`,
37
+ "",
38
+ "Usage:",
39
+ ` ${ansi.cyan("abra-plugin validate")} [path] Validate a manifest against the schema`,
40
+ ` ${ansi.cyan("abra-plugin pack")} [path] [--dry-run] Recompute integrity hash, update manifest`,
41
+ ` ${ansi.cyan("abra-plugin preview-scan")} [path] Validate + static-scan the bundle (registry parity)`,
42
+ "",
43
+ `Path defaults to ${ansi.dim("./manifest.json")} when omitted.`,
44
+ ];
45
+ console.log(lines.join("\n"));
46
+ }
47
+
48
+ function parseFlag(args: string[], flag: string): boolean {
49
+ const idx = args.indexOf(flag);
50
+ if (idx >= 0) {
51
+ args.splice(idx, 1);
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ export function run(argv: ReadonlyArray<string>): number {
58
+ const args = [...argv];
59
+ const command = args.shift();
60
+
61
+ switch (command) {
62
+ case undefined:
63
+ case "help":
64
+ case "-h":
65
+ case "--help":
66
+ help();
67
+ return 0;
68
+
69
+ case "validate": {
70
+ const quiet = parseFlag(args, "--quiet");
71
+ const path = args.shift();
72
+ return validate({ path, quiet }).exitCode;
73
+ }
74
+
75
+ case "pack": {
76
+ const dryRun = parseFlag(args, "--dry-run");
77
+ const path = args.shift();
78
+ return pack({ path, dryRun }).exitCode;
79
+ }
80
+
81
+ case "preview-scan": {
82
+ const path = args.shift();
83
+ return previewScan({ path }).exitCode;
84
+ }
85
+
86
+ default:
87
+ console.error(ansi.red(`error: unknown command "${command}"`));
88
+ console.error(`run ${ansi.cyan("abra-plugin help")} for usage`);
89
+ return 2;
90
+ }
91
+ }
92
+
93
+ // Auto-run when invoked as a CLI (not when imported as a library).
94
+ const isCliEntry =
95
+ import.meta.url === `file://${process.argv[1]}` ||
96
+ import.meta.url.endsWith(process.argv[1] ?? "");
97
+ if (isCliEntry) {
98
+ process.exit(run(process.argv.slice(2)));
99
+ }
package/src/io.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Filesystem helpers used across `abra-plugin` commands. Kept tiny and
3
+ * dependency-free so the CLI bundle stays small.
4
+ */
5
+
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { isAbsolute, resolve } from "node:path";
9
+
10
+ /**
11
+ * Resolve a CLI-supplied path to an absolute path. Relative paths resolve
12
+ * against `process.cwd()`. Used so commands work no matter where the user
13
+ * invoked them from.
14
+ */
15
+ export function resolveCwd(path: string): string {
16
+ return isAbsolute(path) ? path : resolve(process.cwd(), path);
17
+ }
18
+
19
+ /** Read a JSON file and return parsed content. Throws with a contextual message on failure. */
20
+ export function readJsonFile(path: string): unknown {
21
+ let raw: string;
22
+ try {
23
+ raw = readFileSync(path, "utf8");
24
+ } catch (err) {
25
+ const code = (err as NodeJS.ErrnoException).code;
26
+ if (code === "ENOENT") {
27
+ throw new Error(`not found: ${path}`);
28
+ }
29
+ throw new Error(`failed to read ${path}: ${(err as Error).message}`);
30
+ }
31
+ try {
32
+ return JSON.parse(raw) as unknown;
33
+ } catch (err) {
34
+ throw new Error(`invalid JSON in ${path}: ${(err as Error).message}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * SHA-256 hash of a file's bytes, formatted as `sha256-<64 hex chars>` to
40
+ * match the manifest's `integrity` field format.
41
+ */
42
+ export function sha256OfFile(path: string): string {
43
+ const buf = readFileSync(path);
44
+ return `sha256-${createHash("sha256").update(buf).digest("hex")}`;
45
+ }
46
+
47
+ /** Return true if `path` exists and is a regular file. */
48
+ export function isFile(path: string): boolean {
49
+ try {
50
+ return statSync(path).isFile();
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // ── ANSI helpers (no peer dep) ────────────────────────────────────────────────
57
+
58
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
59
+
60
+ export const ansi = {
61
+ red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
62
+ green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
63
+ yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
64
+ cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
65
+ dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
66
+ bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
67
+ };