@ericsanchezok/synergy-plugin-kit 2.2.1 → 2.2.2

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.
@@ -5,6 +5,7 @@ import { PluginManifest } from "@ericsanchezok/synergy-plugin";
5
5
  import { cmd } from "../cmd";
6
6
  import { UI } from "../ui";
7
7
  import { sha256File, sha256JSON } from "../lib/crypto";
8
+ import { collectPackagedAssets, copyPackagedAsset, hashPackagedFiles, rewritePackagedManifestPaths, } from "../lib/artifact-assets";
8
9
  function ensureDir(dirPath) {
9
10
  fs.mkdirSync(dirPath, { recursive: true });
10
11
  }
@@ -19,28 +20,12 @@ function copyDir(src, dest) {
19
20
  fs.copyFileSync(srcPath, destPath);
20
21
  }
21
22
  }
22
- function copyFilePreserve(pluginDir, distDir, relativePath) {
23
- const normalized = relativePath.replace(/^\.\//, "");
24
- const src = path.resolve(pluginDir, normalized);
25
- if (!fs.existsSync(src) || !fs.statSync(src).isFile())
26
- return;
27
- const dest = path.join(distDir, normalized);
28
- ensureDir(path.dirname(dest));
29
- fs.copyFileSync(src, dest);
30
- }
31
23
  function findUiSource(pluginDir) {
32
24
  const candidates = ["src/ui.tsx", "src/ui/index.tsx", "src/ui.ts", "src/ui/index.ts"];
33
25
  return candidates.map((candidate) => path.join(pluginDir, candidate)).find((candidate) => fs.existsSync(candidate));
34
26
  }
35
27
  function packagedManifest(manifest) {
36
- const next = structuredClone(manifest);
37
- next.main = "./runtime/index.js";
38
- if (next.contributes?.ui?.entry) {
39
- next.contributes.ui.entry = next.contributes.ui.entry.replace(/^\.\//, "").replace(/^dist\//, "./");
40
- if (!next.contributes.ui.entry.startsWith("."))
41
- next.contributes.ui.entry = `./${next.contributes.ui.entry}`;
42
- }
43
- return next;
28
+ return rewritePackagedManifestPaths(manifest);
44
29
  }
45
30
  function permissionSummary(manifest) {
46
31
  const perms = manifest.permissions ?? {};
@@ -136,6 +121,16 @@ export async function buildPluginProject(pluginDir) {
136
121
  return false;
137
122
  }
138
123
  }
124
+ spinner("Copying declared assets");
125
+ try {
126
+ for (const asset of collectPackagedAssets(manifest)) {
127
+ copyPackagedAsset(pluginDir, distDir, asset);
128
+ }
129
+ }
130
+ catch (error) {
131
+ UI.error(error instanceof Error ? error.message : String(error));
132
+ return false;
133
+ }
139
134
  spinner("Normalizing manifest");
140
135
  const distManifest = packagedManifest(manifest);
141
136
  const distManifestPath = path.join(distDir, "plugin.json");
@@ -157,10 +152,6 @@ export async function buildPluginProject(pluginDir) {
157
152
  spinner("Copying assets");
158
153
  copyDir(publicAssetsPath, path.join(distDir, "assets"));
159
154
  }
160
- for (const theme of manifest.contributes?.ui?.themes ?? [])
161
- copyFilePreserve(pluginDir, distDir, theme.path);
162
- for (const icon of manifest.contributes?.ui?.icons ?? [])
163
- copyFilePreserve(pluginDir, distDir, icon.path);
164
155
  spinner("Computing integrity hashes");
165
156
  const integrity = {
166
157
  manifest: sha256File(distManifestPath),
@@ -174,7 +165,11 @@ export async function buildPluginProject(pluginDir) {
174
165
  if (fs.existsSync(uiIndex))
175
166
  integrity.ui = sha256File(uiIndex);
176
167
  }
177
- fs.writeFileSync(path.join(distDir, "integrity.json"), JSON.stringify(integrity, null, 2));
168
+ const integrityPayload = {
169
+ ...integrity,
170
+ files: hashPackagedFiles(distDir),
171
+ };
172
+ fs.writeFileSync(path.join(distDir, "integrity.json"), JSON.stringify(integrityPayload, null, 2));
178
173
  UI.println(`${UI.Style.TEXT_SUCCESS}✔${UI.Style.TEXT_NORMAL} Built ${manifest.name} v${manifest.version} -> ${distDir}`);
179
174
  UI.println(` ${UI.Style.TEXT_DIM}Output:${UI.Style.TEXT_NORMAL} ${distDir}`);
180
175
  return true;
@@ -4,6 +4,7 @@ import { PluginManifest } from "@ericsanchezok/synergy-plugin";
4
4
  import { cmd } from "../cmd";
5
5
  import { UI } from "../ui";
6
6
  import { sha256File } from "../lib/crypto";
7
+ import { missingPackagedAssets } from "../lib/artifact-assets";
7
8
  function formatSize(bytes) {
8
9
  if (bytes < 1024)
9
10
  return `${bytes} B`;
@@ -31,6 +32,17 @@ export function packPluginProject(pluginDir) {
31
32
  if (!fs.existsSync(path.join(distDir, "plugin.json"))) {
32
33
  throw new Error(`dist/plugin.json not found at ${distDir}. Run "synergy-plugin build" first.`);
33
34
  }
35
+ for (const required of ["runtime/index.js", "integrity.json", "permissions.summary.json"]) {
36
+ if (!fs.existsSync(path.join(distDir, required))) {
37
+ throw new Error(`dist/${required} not found at ${distDir}. Run "synergy-plugin build" first.`);
38
+ }
39
+ }
40
+ const distManifest = PluginManifest.parse(JSON.parse(fs.readFileSync(path.join(distDir, "plugin.json"), "utf-8")));
41
+ const missing = missingPackagedAssets(distDir, distManifest);
42
+ if (missing.length > 0) {
43
+ const details = missing.map((asset) => ` - ${asset.label}: ${asset.packageRelative}`).join("\n");
44
+ throw new Error(`dist/ is missing manifest-declared plugin assets:\n${details}`);
45
+ }
34
46
  const tgzName = `${safePackageName(manifest.name)}-${manifest.version}.synergy-plugin.tgz`;
35
47
  const result = Bun.spawnSync(["tar", "-czf", tgzName, "-C", distDir, "."], { cwd: pluginDir });
36
48
  if (result.exitCode !== 0) {
@@ -6,7 +6,9 @@ import { PluginManifest } from "@ericsanchezok/synergy-plugin";
6
6
  import { cmd } from "../cmd";
7
7
  import { UI } from "../ui";
8
8
  import { SIGNING_KEYS_DIR, SIGNING_KEY_FILE } from "../lib/paths";
9
- import { sha256Content, sha256File } from "../lib/crypto";
9
+ import { sha256File } from "../lib/crypto";
10
+ import { baseCapabilities } from "../lib/capability";
11
+ import { computeManifestHash, computePermissionsHash } from "../lib/hash";
10
12
  function extractFromTarball(tarballPath, memberPath) {
11
13
  const result = Bun.spawnSync(["tar", "-xOf", tarballPath, memberPath], { stdout: "pipe", stderr: "pipe" });
12
14
  if (result.exitCode !== 0)
@@ -52,8 +54,7 @@ export async function signPluginTarball(tarballPath, options = {}) {
52
54
  catch {
53
55
  throw new Error("Failed to parse plugin.normalized.json from tarball");
54
56
  }
55
- const permissionsRaw = extractFromTarball(tarballPath, "permissions.summary.json");
56
- if (!permissionsRaw) {
57
+ if (!extractFromTarball(tarballPath, "permissions.summary.json")) {
57
58
  throw new Error("Failed to extract permissions.summary.json from tarball. Has the plugin been built?");
58
59
  }
59
60
  let keyFile = readKeyFile();
@@ -66,8 +67,8 @@ export async function signPluginTarball(tarballPath, options = {}) {
66
67
  }
67
68
  const payload = {
68
69
  tarballHash,
69
- manifestHash: sha256Content(manifestRaw),
70
- permissionsHash: sha256Content(permissionsRaw),
70
+ manifestHash: computeManifestHash(manifest),
71
+ permissionsHash: computePermissionsHash(manifest, baseCapabilities(manifest)),
71
72
  };
72
73
  const privateKey = await importPrivateKey(keyFile.privateKey);
73
74
  const sigRaw = await subtle.sign("Ed25519", privateKey, new TextEncoder().encode(JSON.stringify(payload)));
@@ -10,6 +10,7 @@ import { computeRisk } from "../lib/risk";
10
10
  import { validateRuntimePolicy } from "../lib/runtime-policy";
11
11
  import { validateRuntimeDiscovery } from "../lib/runtime-discovery";
12
12
  import { assertCanonicalPluginIdentity, importUrlForEntry, resolveEntryFromPluginDir } from "../lib/spec";
13
+ import { collectPackagedAssets, resolveUnder } from "../lib/artifact-assets";
13
14
  function scanExports(source) {
14
15
  const names = new Set();
15
16
  const declRe = /^export\s+(?:const|function|class|interface|type|let|var)\s+(\w+)/gm;
@@ -183,21 +184,35 @@ export async function validatePluginProject(pluginPath, options = {}) {
183
184
  checkExport("uiCommand", ui.commands);
184
185
  }
185
186
  }
186
- for (const icon of ui.icons ?? []) {
187
- if (!fs.existsSync(path.resolve(pluginDir, icon.path))) {
188
- results.push({ type: "error", message: `icon "${icon.name}" path ${icon.path} not found` });
187
+ }
188
+ const uiSource = findUiSource(pluginDir);
189
+ let packagedAssets = [];
190
+ try {
191
+ packagedAssets = collectPackagedAssets(m);
192
+ }
193
+ catch (error) {
194
+ results.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
195
+ }
196
+ for (const asset of packagedAssets) {
197
+ if (asset.label === "UI entry" && uiSource && !fs.existsSync(path.resolve(pluginDir, asset.sourceRelative)))
198
+ continue;
199
+ try {
200
+ const assetPath = resolveUnder(pluginDir, asset.sourceRelative);
201
+ if (!fs.existsSync(assetPath)) {
202
+ results.push({ type: "error", message: `${asset.label} ${asset.sourceRelative} not found` });
203
+ continue;
189
204
  }
190
- }
191
- for (const theme of ui.themes ?? []) {
192
- if (!fs.existsSync(path.resolve(pluginDir, theme.path))) {
193
- results.push({ type: "error", message: `theme "${theme.id}" path ${theme.path} not found` });
205
+ const stat = fs.statSync(assetPath);
206
+ if (asset.kind === "dir" && !stat.isDirectory()) {
207
+ results.push({ type: "error", message: `${asset.label} ${asset.sourceRelative} is not a directory` });
194
208
  }
195
- }
196
- for (const route of ui.routes ?? []) {
197
- if (!fs.existsSync(path.resolve(pluginDir, route.entry))) {
198
- results.push({ type: "error", message: `route "${route.path}" entry ${route.entry} not found` });
209
+ if (asset.kind === "file" && !stat.isFile()) {
210
+ results.push({ type: "error", message: `${asset.label} ${asset.sourceRelative} is not a file` });
199
211
  }
200
212
  }
213
+ catch (error) {
214
+ results.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
215
+ }
201
216
  }
202
217
  for (const tool of m.contributes?.tools ?? []) {
203
218
  if (!tool.capabilities)
@@ -207,7 +222,10 @@ export async function validatePluginProject(pluginPath, options = {}) {
207
222
  if (isValidJsonSchema(m.contributes.config.schema))
208
223
  results.push({ type: "pass", message: "config schema valid" });
209
224
  else
210
- results.push({ type: "warn", message: "config schema does not appear to be valid JSON Schema" });
225
+ results.push({
226
+ type: "warn",
227
+ message: 'config schema does not appear to be valid JSON Schema; wrap plugin settings in a top-level schema such as { "type": "object", "properties": { ... } }',
228
+ });
211
229
  }
212
230
  const pluginRisk = computeRisk(baseCapabilities(m), m);
213
231
  results.push(...validateRuntimePolicy({ manifest: m, source: "local", trustTier: "declarative", risk: pluginRisk }));
@@ -225,9 +243,14 @@ export async function validatePluginProject(pluginPath, options = {}) {
225
243
  try {
226
244
  const mod = await import(importUrlForEntry(entryPath, Date.now()));
227
245
  const descriptors = [];
246
+ const seenDescriptors = new Set();
228
247
  for (const value of Object.values(mod)) {
229
248
  if (value && typeof value === "object" && !Array.isArray(value) && "id" in value && "init" in value) {
230
- descriptors.push(value);
249
+ const descriptor = value;
250
+ if (!seenDescriptors.has(descriptor)) {
251
+ descriptors.push(descriptor);
252
+ seenDescriptors.add(descriptor);
253
+ }
231
254
  }
232
255
  }
233
256
  if (descriptors.length === 0) {
@@ -0,0 +1,17 @@
1
+ import type { PluginManifest } from "@ericsanchezok/synergy-plugin";
2
+ export interface PackagedAsset {
3
+ label: string;
4
+ kind: "file" | "dir";
5
+ sourceRelative: string;
6
+ packageRelative: string;
7
+ }
8
+ export declare function isLocalManifestPath(value: string | undefined): value is string;
9
+ export declare function normalizeManifestPath(value: string): string;
10
+ export declare function packageRelativePath(value: string): string;
11
+ export declare function packageManifestPath(value: string): string;
12
+ export declare function collectPackagedAssets(manifest: PluginManifest): PackagedAsset[];
13
+ export declare function rewritePackagedManifestPaths(manifest: PluginManifest): PluginManifest;
14
+ export declare function resolveUnder(root: string, relativePath: string): string;
15
+ export declare function copyPackagedAsset(pluginDir: string, distDir: string, asset: PackagedAsset): void;
16
+ export declare function hashPackagedFiles(distDir: string): Record<string, string>;
17
+ export declare function missingPackagedAssets(distDir: string, manifest: PluginManifest): PackagedAsset[];
@@ -0,0 +1,158 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { sha256File } from "./crypto";
4
+ function isExternalPath(value) {
5
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) || value.startsWith("//");
6
+ }
7
+ export function isLocalManifestPath(value) {
8
+ return Boolean(value && !isExternalPath(value));
9
+ }
10
+ export function normalizeManifestPath(value) {
11
+ const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "");
12
+ if (!normalized || normalized === ".")
13
+ throw new Error("Manifest path cannot be empty");
14
+ if (path.posix.isAbsolute(normalized) || path.isAbsolute(value)) {
15
+ throw new Error(`Manifest path must be relative: ${value}`);
16
+ }
17
+ const parts = normalized.split("/");
18
+ if (parts.includes(".."))
19
+ throw new Error(`Manifest path cannot escape the plugin directory: ${value}`);
20
+ return path.posix.normalize(normalized);
21
+ }
22
+ export function packageRelativePath(value) {
23
+ const normalized = normalizeManifestPath(value);
24
+ return normalized.startsWith("dist/") ? normalized.slice("dist/".length) : normalized;
25
+ }
26
+ export function packageManifestPath(value) {
27
+ return `./${packageRelativePath(value)}`;
28
+ }
29
+ function addAsset(assets, input) {
30
+ if (!isLocalManifestPath(input.path))
31
+ return;
32
+ assets.push({
33
+ label: input.label,
34
+ kind: input.kind,
35
+ sourceRelative: normalizeManifestPath(input.path),
36
+ packageRelative: packageRelativePath(input.path),
37
+ });
38
+ }
39
+ function addSandboxEntries(assets, label, entries) {
40
+ for (const entry of entries ?? []) {
41
+ addAsset(assets, {
42
+ label: `${label}${entry.id ? ` "${entry.id}"` : ""} sandbox entry`,
43
+ kind: "file",
44
+ path: entry.sandboxEntry,
45
+ });
46
+ }
47
+ }
48
+ export function collectPackagedAssets(manifest) {
49
+ const assets = [];
50
+ for (const skill of manifest.contributes?.skills ?? []) {
51
+ addAsset(assets, { label: `skill "${skill.name}" directory`, kind: "dir", path: skill.dir });
52
+ }
53
+ if (manifest.lifecycle?.install)
54
+ addAsset(assets, { label: "install lifecycle script", kind: "file", path: manifest.lifecycle.install });
55
+ if (manifest.lifecycle?.uninstall)
56
+ addAsset(assets, { label: "uninstall lifecycle script", kind: "file", path: manifest.lifecycle.uninstall });
57
+ if (manifest.lifecycle?.update)
58
+ addAsset(assets, { label: "update lifecycle script", kind: "file", path: manifest.lifecycle.update });
59
+ const ui = manifest.contributes?.ui;
60
+ if (ui?.entry)
61
+ addAsset(assets, { label: "UI entry", kind: "file", path: ui.entry });
62
+ for (const route of ui?.routes ?? []) {
63
+ addAsset(assets, { label: `route "${route.path}" entry`, kind: "file", path: route.entry });
64
+ }
65
+ addSandboxEntries(assets, "workspace panel", ui?.workspacePanels);
66
+ addSandboxEntries(assets, "global panel", ui?.globalPanels);
67
+ addSandboxEntries(assets, "settings", ui?.settings);
68
+ for (const theme of ui?.themes ?? [])
69
+ addAsset(assets, { label: `theme "${theme.id}"`, kind: "file", path: theme.path });
70
+ for (const icon of ui?.icons ?? [])
71
+ addAsset(assets, { label: `icon "${icon.name}"`, kind: "file", path: icon.path });
72
+ const seen = new Set();
73
+ return assets.filter((asset) => {
74
+ const key = `${asset.kind}:${asset.packageRelative}`;
75
+ if (seen.has(key))
76
+ return false;
77
+ seen.add(key);
78
+ return true;
79
+ });
80
+ }
81
+ export function rewritePackagedManifestPaths(manifest) {
82
+ const next = structuredClone(manifest);
83
+ next.main = "./runtime/index.js";
84
+ const ui = next.contributes?.ui;
85
+ if (!ui)
86
+ return next;
87
+ if (ui.entry)
88
+ ui.entry = packageManifestPath(ui.entry);
89
+ for (const route of ui.routes ?? [])
90
+ route.entry = packageManifestPath(route.entry);
91
+ for (const panel of ui.workspacePanels ?? []) {
92
+ if (panel.sandboxEntry)
93
+ panel.sandboxEntry = packageManifestPath(panel.sandboxEntry);
94
+ }
95
+ for (const panel of ui.globalPanels ?? []) {
96
+ if (panel.sandboxEntry)
97
+ panel.sandboxEntry = packageManifestPath(panel.sandboxEntry);
98
+ }
99
+ for (const settings of ui.settings ?? []) {
100
+ if (settings.sandboxEntry)
101
+ settings.sandboxEntry = packageManifestPath(settings.sandboxEntry);
102
+ }
103
+ return next;
104
+ }
105
+ export function resolveUnder(root, relativePath) {
106
+ const normalized = normalizeManifestPath(relativePath);
107
+ const resolved = path.resolve(root, normalized);
108
+ const relative = path.relative(root, resolved);
109
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
110
+ throw new Error(`Manifest path cannot escape ${root}: ${relativePath}`);
111
+ }
112
+ return resolved;
113
+ }
114
+ export function copyPackagedAsset(pluginDir, distDir, asset) {
115
+ const src = resolveUnder(pluginDir, asset.sourceRelative);
116
+ const dest = resolveUnder(distDir, asset.packageRelative);
117
+ if (!fs.existsSync(src))
118
+ throw new Error(`${asset.label} not found at ${asset.sourceRelative}`);
119
+ const stat = fs.statSync(src);
120
+ if (asset.kind === "dir") {
121
+ if (!stat.isDirectory())
122
+ throw new Error(`${asset.label} must be a directory: ${asset.sourceRelative}`);
123
+ if (path.resolve(src) === path.resolve(dest))
124
+ return;
125
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
126
+ fs.cpSync(src, dest, { recursive: true });
127
+ return;
128
+ }
129
+ if (!stat.isFile())
130
+ throw new Error(`${asset.label} must be a file: ${asset.sourceRelative}`);
131
+ if (path.resolve(src) === path.resolve(dest))
132
+ return;
133
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
134
+ fs.copyFileSync(src, dest);
135
+ }
136
+ function walkFiles(root, dir, output) {
137
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
138
+ const filepath = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ walkFiles(root, filepath, output);
141
+ continue;
142
+ }
143
+ if (!entry.isFile())
144
+ continue;
145
+ const relative = path.relative(root, filepath).split(path.sep).join("/");
146
+ if (relative === "integrity.json")
147
+ continue;
148
+ output[relative] = sha256File(filepath);
149
+ }
150
+ }
151
+ export function hashPackagedFiles(distDir) {
152
+ const files = {};
153
+ walkFiles(distDir, distDir, files);
154
+ return Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)));
155
+ }
156
+ export function missingPackagedAssets(distDir, manifest) {
157
+ return collectPackagedAssets(manifest).filter((asset) => !fs.existsSync(resolveUnder(distDir, asset.packageRelative)));
158
+ }
package/dist/lib/hash.js CHANGED
@@ -3,12 +3,11 @@ export function computePermissionsHash(manifest, capabilities) {
3
3
  const normalized = {
4
4
  capabilities: [...capabilities].sort(),
5
5
  permissions: manifest.permissions ?? {},
6
- contributes: manifest.contributes?.ui != null ? { ui: manifest.contributes.ui } : undefined,
7
- hooks: manifest.permissions?.hooks ?? undefined,
6
+ contributes: manifest.contributes ?? {},
7
+ lifecycle: manifest.lifecycle ?? {},
8
8
  };
9
9
  return sha256Content(JSON.stringify(sortKeys(normalized)));
10
10
  }
11
11
  export function computeManifestHash(manifest) {
12
- const { contributes, lifecycle, permissions, ...identity } = manifest;
13
- return sha256Content(JSON.stringify(sortKeys(identity)));
12
+ return sha256Content(JSON.stringify(sortKeys(manifest)));
14
13
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@ericsanchezok/synergy-plugin-kit",
4
- "version": "2.2.1",
4
+ "version": "2.2.2",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "dist"
35
35
  ],
36
36
  "dependencies": {
37
- "@ericsanchezok/synergy-plugin": "2.2.0",
37
+ "@ericsanchezok/synergy-plugin": "2.2.1",
38
38
  "yargs": "18.0.0"
39
39
  },
40
40
  "peerDependencies": {