@oh-my-pi/cli 0.1.0 → 0.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.
Files changed (105) hide show
  1. package/.github/icon.png +0 -0
  2. package/.github/logo.png +0 -0
  3. package/.github/workflows/publish.yml +1 -1
  4. package/LICENSE +21 -0
  5. package/README.md +243 -138
  6. package/biome.json +1 -1
  7. package/bun.lock +59 -0
  8. package/dist/cli.js +6311 -2900
  9. package/dist/commands/config.d.ts +32 -0
  10. package/dist/commands/config.d.ts.map +1 -0
  11. package/dist/commands/create.d.ts.map +1 -1
  12. package/dist/commands/doctor.d.ts +1 -0
  13. package/dist/commands/doctor.d.ts.map +1 -1
  14. package/dist/commands/enable.d.ts +1 -0
  15. package/dist/commands/enable.d.ts.map +1 -1
  16. package/dist/commands/env.d.ts +14 -0
  17. package/dist/commands/env.d.ts.map +1 -0
  18. package/dist/commands/features.d.ts +25 -0
  19. package/dist/commands/features.d.ts.map +1 -0
  20. package/dist/commands/info.d.ts +1 -0
  21. package/dist/commands/info.d.ts.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/install.d.ts +37 -0
  24. package/dist/commands/install.d.ts.map +1 -1
  25. package/dist/commands/link.d.ts +2 -0
  26. package/dist/commands/link.d.ts.map +1 -1
  27. package/dist/commands/list.d.ts +1 -0
  28. package/dist/commands/list.d.ts.map +1 -1
  29. package/dist/commands/outdated.d.ts +1 -0
  30. package/dist/commands/outdated.d.ts.map +1 -1
  31. package/dist/commands/search.d.ts.map +1 -1
  32. package/dist/commands/uninstall.d.ts +1 -0
  33. package/dist/commands/uninstall.d.ts.map +1 -1
  34. package/dist/commands/update.d.ts +1 -0
  35. package/dist/commands/update.d.ts.map +1 -1
  36. package/dist/commands/why.d.ts +1 -0
  37. package/dist/commands/why.d.ts.map +1 -1
  38. package/dist/conflicts.d.ts +9 -1
  39. package/dist/conflicts.d.ts.map +1 -1
  40. package/dist/errors.d.ts +8 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/index.d.ts +18 -19
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/lock.d.ts +3 -0
  45. package/dist/lock.d.ts.map +1 -0
  46. package/dist/lockfile.d.ts +52 -0
  47. package/dist/lockfile.d.ts.map +1 -0
  48. package/dist/manifest.d.ts +60 -25
  49. package/dist/manifest.d.ts.map +1 -1
  50. package/dist/npm.d.ts +14 -2
  51. package/dist/npm.d.ts.map +1 -1
  52. package/dist/paths.d.ts +34 -3
  53. package/dist/paths.d.ts.map +1 -1
  54. package/dist/runtime.d.ts +14 -0
  55. package/dist/runtime.d.ts.map +1 -0
  56. package/dist/symlinks.d.ts +43 -7
  57. package/dist/symlinks.d.ts.map +1 -1
  58. package/package.json +11 -5
  59. package/plugins/exa/README.md +153 -0
  60. package/plugins/exa/package.json +56 -0
  61. package/plugins/exa/tools/exa/company.ts +35 -0
  62. package/plugins/exa/tools/exa/index.ts +66 -0
  63. package/plugins/exa/tools/exa/linkedin.ts +35 -0
  64. package/plugins/exa/tools/exa/researcher.ts +40 -0
  65. package/plugins/exa/tools/exa/runtime.json +4 -0
  66. package/plugins/exa/tools/exa/search.ts +46 -0
  67. package/plugins/exa/tools/exa/shared.ts +230 -0
  68. package/plugins/exa/tools/exa/websets.ts +62 -0
  69. package/plugins/metal-theme/package.json +7 -2
  70. package/plugins/subagents/package.json +7 -2
  71. package/plugins/user-prompt/README.md +130 -0
  72. package/plugins/user-prompt/package.json +19 -0
  73. package/plugins/user-prompt/tools/user-prompt/index.ts +235 -0
  74. package/src/cli.ts +133 -58
  75. package/src/commands/config.ts +384 -0
  76. package/src/commands/create.ts +51 -1
  77. package/src/commands/doctor.ts +95 -7
  78. package/src/commands/enable.ts +25 -8
  79. package/src/commands/env.ts +38 -0
  80. package/src/commands/features.ts +295 -0
  81. package/src/commands/info.ts +41 -5
  82. package/src/commands/init.ts +20 -2
  83. package/src/commands/install.ts +453 -80
  84. package/src/commands/link.ts +60 -9
  85. package/src/commands/list.ts +122 -7
  86. package/src/commands/outdated.ts +17 -6
  87. package/src/commands/search.ts +20 -3
  88. package/src/commands/uninstall.ts +57 -6
  89. package/src/commands/update.ts +67 -9
  90. package/src/commands/why.ts +47 -16
  91. package/src/conflicts.ts +33 -1
  92. package/src/errors.ts +22 -0
  93. package/src/index.ts +18 -25
  94. package/src/lock.ts +46 -0
  95. package/src/lockfile.ts +132 -0
  96. package/src/manifest.ts +219 -71
  97. package/src/npm.ts +74 -18
  98. package/src/paths.ts +77 -12
  99. package/src/runtime.ts +116 -0
  100. package/src/symlinks.ts +291 -35
  101. package/tsconfig.json +7 -3
  102. package/CHECK.md +0 -352
  103. package/dist/migrate.d.ts +0 -9
  104. package/dist/migrate.d.ts.map +0 -1
  105. package/src/migrate.ts +0 -181
package/src/runtime.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { OmpVariable, PluginPackageJson, PluginsJson } from "@omp/manifest";
2
+ import { loadPluginsJson, readPluginPackageJson } from "@omp/manifest";
3
+
4
+ /**
5
+ * Collect all variables from a plugin (top-level + enabled features)
6
+ */
7
+ function collectVariables(
8
+ pkgJson: PluginPackageJson,
9
+ enabledFeatures: string[],
10
+ ): Record<string, OmpVariable> {
11
+ const vars: Record<string, OmpVariable> = {};
12
+
13
+ // Top-level variables
14
+ if (pkgJson.omp?.variables) {
15
+ Object.assign(vars, pkgJson.omp.variables);
16
+ }
17
+
18
+ // Variables from enabled features
19
+ if (pkgJson.omp?.features) {
20
+ for (const fname of enabledFeatures) {
21
+ const feature = pkgJson.omp.features[fname];
22
+ if (feature?.variables) {
23
+ Object.assign(vars, feature.variables);
24
+ }
25
+ }
26
+ }
27
+
28
+ return vars;
29
+ }
30
+
31
+ /**
32
+ * Resolve which features are currently enabled
33
+ *
34
+ * - null/undefined: use plugin defaults (features with default !== false)
35
+ * - ["*"]: explicitly all features
36
+ * - []: no optional features
37
+ * - ["f1", "f2"]: specific features
38
+ */
39
+ function resolveEnabledFeatures(
40
+ allFeatureNames: string[],
41
+ storedFeatures: string[] | null | undefined,
42
+ pluginFeatures: Record<string, { default?: boolean }>,
43
+ ): string[] {
44
+ // Explicit "all features" request
45
+ if (Array.isArray(storedFeatures) && storedFeatures.includes("*")) return allFeatureNames;
46
+ // Explicit feature list (including empty array = no features)
47
+ if (Array.isArray(storedFeatures)) return storedFeatures;
48
+ // null/undefined = use defaults
49
+ return Object.entries(pluginFeatures)
50
+ .filter(([_, f]) => f.default !== false)
51
+ .map(([name]) => name);
52
+ }
53
+
54
+ /**
55
+ * Get all environment variables for enabled plugins
56
+ */
57
+ export async function getPluginEnvVars(global = true): Promise<Record<string, string>> {
58
+ const pluginsJson = await loadPluginsJson(global);
59
+ const env: Record<string, string> = {};
60
+
61
+ for (const pluginName of Object.keys(pluginsJson.plugins)) {
62
+ // Skip disabled plugins
63
+ if (pluginsJson.disabled?.includes(pluginName)) continue;
64
+
65
+ const pkgJson = await readPluginPackageJson(pluginName, global);
66
+ if (!pkgJson?.omp) continue;
67
+
68
+ const config = pluginsJson.config?.[pluginName];
69
+ const allFeatureNames = Object.keys(pkgJson.omp.features || {});
70
+ const enabledFeatures = resolveEnabledFeatures(
71
+ allFeatureNames,
72
+ config?.features,
73
+ pkgJson.omp.features || {},
74
+ );
75
+
76
+ // Collect variables from top-level and enabled features
77
+ const variables = collectVariables(pkgJson, enabledFeatures);
78
+
79
+ for (const [key, varDef] of Object.entries(variables)) {
80
+ if (varDef.env) {
81
+ const value = config?.variables?.[key] ?? varDef.default;
82
+ if (value !== undefined) {
83
+ env[varDef.env] = String(value);
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ return env;
90
+ }
91
+
92
+ /**
93
+ * Generate shell export statements
94
+ * omp env > ~/.pi/env.sh && source ~/.pi/env.sh
95
+ */
96
+ export async function generateEnvScript(global = true, shell: "sh" | "fish" = "sh"): Promise<string> {
97
+ const vars = await getPluginEnvVars(global);
98
+
99
+ if (shell === "fish") {
100
+ return Object.entries(vars)
101
+ .map(([k, v]) => `set -gx ${k} ${JSON.stringify(v)}`)
102
+ .join("\n");
103
+ }
104
+
105
+ // POSIX sh/bash/zsh
106
+ return Object.entries(vars)
107
+ .map(([k, v]) => `export ${k}=${JSON.stringify(v)}`)
108
+ .join("\n");
109
+ }
110
+
111
+ /**
112
+ * Get environment variables as a JSON object for programmatic use
113
+ */
114
+ export async function getEnvJson(global = true): Promise<Record<string, string>> {
115
+ return getPluginEnvVars(global);
116
+ }
package/src/symlinks.ts CHANGED
@@ -1,39 +1,135 @@
1
- import { existsSync, lstatSync } from "node:fs";
2
- import { mkdir, readlink, rm, symlink } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
1
+ import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { copyFile, mkdir, readlink, rm, symlink } from "node:fs/promises";
3
+ import { platform } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import type { OmpFeature, OmpInstallEntry, PluginPackageJson, PluginRuntimeConfig } from "@omp/manifest";
6
+ import { getPluginSourceDir } from "@omp/manifest";
7
+ import { PI_CONFIG_DIR, PROJECT_PI_DIR } from "@omp/paths";
4
8
  import chalk from "chalk";
5
- import type { OmpInstallEntry, PluginPackageJson } from "./manifest.js";
6
- import { getPluginSourceDir } from "./manifest.js";
7
- import { PI_CONFIG_DIR } from "./paths.js";
9
+
10
+ /**
11
+ * Get all install entries from package.json.
12
+ * Features no longer have install entries - all files are always installed.
13
+ */
14
+ export function getInstallEntries(pkgJson: PluginPackageJson): OmpInstallEntry[] {
15
+ return pkgJson.omp?.install ?? [];
16
+ }
17
+
18
+ /**
19
+ * @deprecated Use getInstallEntries instead. Features no longer have install arrays.
20
+ */
21
+ export function getEnabledInstallEntries(
22
+ pkgJson: PluginPackageJson,
23
+ _enabledFeatures?: string[],
24
+ ): OmpInstallEntry[] {
25
+ return getInstallEntries(pkgJson);
26
+ }
27
+
28
+ /**
29
+ * Get all available feature names from a plugin
30
+ */
31
+ export function getAllFeatureNames(pkgJson: PluginPackageJson): string[] {
32
+ return Object.keys(pkgJson.omp?.features || {});
33
+ }
34
+
35
+ /**
36
+ * Get features that are enabled by default (default !== false)
37
+ */
38
+ export function getDefaultFeatures(features: Record<string, OmpFeature>): string[] {
39
+ return Object.entries(features)
40
+ .filter(([_, f]) => f.default !== false)
41
+ .map(([name]) => name);
42
+ }
43
+
44
+ const isWindows = platform() === "win32";
45
+
46
+ /**
47
+ * Format permission-related errors with actionable guidance
48
+ */
49
+ function formatPermissionError(err: NodeJS.ErrnoException, path: string): string {
50
+ if (err.code === "EACCES" || err.code === "EPERM") {
51
+ return `Permission denied: Cannot write to ${path}. Check directory permissions or run with appropriate privileges.`;
52
+ }
53
+ return err.message;
54
+ }
55
+
56
+ /**
57
+ * Validates that a target path stays within the base directory.
58
+ * Prevents path traversal attacks via malicious dest entries like '../../../etc/passwd'.
59
+ */
60
+ function isPathWithinBase(basePath: string, targetPath: string): boolean {
61
+ const normalizedBase = resolve(basePath);
62
+ const resolvedTarget = resolve(basePath, targetPath);
63
+ // Must start with base path followed by separator (or be exactly the base)
64
+ return resolvedTarget === normalizedBase || resolvedTarget.startsWith(`${normalizedBase}/`);
65
+ }
66
+
67
+ /**
68
+ * Get the base directory for symlink destinations based on scope
69
+ */
70
+ function getBaseDir(global: boolean): string {
71
+ return global ? PI_CONFIG_DIR : PROJECT_PI_DIR;
72
+ }
8
73
 
9
74
  export interface SymlinkResult {
10
75
  created: string[];
11
76
  errors: string[];
12
77
  }
13
78
 
79
+ export interface SymlinkRemovalResult {
80
+ removed: string[];
81
+ errors: string[];
82
+ skippedNonSymlinks: string[]; // Files that exist but aren't symlinks
83
+ }
84
+
14
85
  /**
15
- * Create symlinks for a plugin's omp.install entries
86
+ * Create symlinks (or copy files with copy:true) for a plugin's omp.install entries
87
+ * @param skipDestinations - Set of destination paths to skip (e.g., due to conflict resolution)
88
+ * @param enabledFeatures - Features to write into runtime.json (if plugin has one)
16
89
  */
17
90
  export async function createPluginSymlinks(
18
91
  pluginName: string,
19
92
  pkgJson: PluginPackageJson,
20
93
  global = true,
21
94
  verbose = true,
95
+ skipDestinations?: Set<string>,
96
+ enabledFeatures?: string[],
22
97
  ): Promise<SymlinkResult> {
23
98
  const result: SymlinkResult = { created: [], errors: [] };
24
99
  const sourceDir = getPluginSourceDir(pluginName, global);
25
100
 
26
- if (!pkgJson.omp?.install?.length) {
101
+ const installEntries = getInstallEntries(pkgJson);
102
+ if (installEntries.length === 0) {
27
103
  if (verbose) {
28
104
  console.log(chalk.dim(" No omp.install entries found"));
29
105
  }
30
106
  return result;
31
107
  }
32
108
 
33
- for (const entry of pkgJson.omp.install) {
109
+ const baseDir = getBaseDir(global);
110
+
111
+ for (const entry of installEntries) {
112
+ // Skip destinations that the user chose to keep from existing plugins
113
+ if (skipDestinations?.has(entry.dest)) {
114
+ if (verbose) {
115
+ console.log(chalk.dim(` Skipped: ${entry.dest} (conflict resolved to existing plugin)`));
116
+ }
117
+ continue;
118
+ }
119
+
120
+ // Validate dest path stays within base directory (prevents path traversal attacks)
121
+ if (!isPathWithinBase(baseDir, entry.dest)) {
122
+ const msg = `Path traversal blocked: ${entry.dest} escapes base directory`;
123
+ result.errors.push(msg);
124
+ if (verbose) {
125
+ console.log(chalk.red(` ✗ ${msg}`));
126
+ }
127
+ continue;
128
+ }
129
+
34
130
  try {
35
131
  const src = join(sourceDir, entry.src);
36
- const dest = join(PI_CONFIG_DIR, entry.dest);
132
+ const dest = join(baseDir, entry.dest);
37
133
 
38
134
  // Check if source exists
39
135
  if (!existsSync(src)) {
@@ -47,60 +143,198 @@ export async function createPluginSymlinks(
47
143
  // Create parent directory
48
144
  await mkdir(dirname(dest), { recursive: true });
49
145
 
50
- // Remove existing symlink/file if it exists
51
- try {
52
- await rm(dest, { force: true, recursive: true });
53
- } catch {}
146
+ // Handle copy vs symlink
147
+ if (entry.copy) {
148
+ // For copy entries (like runtime.json), copy the file
149
+ // But DON'T overwrite if it already exists (preserves user edits)
150
+ if (!existsSync(dest)) {
151
+ await copyFile(src, dest);
152
+ result.created.push(entry.dest);
153
+ if (verbose) {
154
+ console.log(chalk.dim(` Copied: ${entry.dest} (from ${entry.src})`));
155
+ }
156
+ } else {
157
+ if (verbose) {
158
+ console.log(chalk.dim(` Exists: ${entry.dest} (preserved)`));
159
+ }
160
+ }
161
+ } else {
162
+ // Remove existing symlink/file if it exists
163
+ try {
164
+ await rm(dest, { force: true, recursive: true });
165
+ } catch {}
54
166
 
55
- // Create symlink
56
- await symlink(src, dest);
57
- result.created.push(entry.dest);
167
+ // Create symlink (use junctions on Windows for directories to avoid admin requirement)
168
+ try {
169
+ if (isWindows) {
170
+ const stats = lstatSync(src);
171
+ if (stats.isDirectory()) {
172
+ await symlink(src, dest, "junction");
173
+ } else {
174
+ await symlink(src, dest, "file");
175
+ }
176
+ } else {
177
+ await symlink(src, dest);
178
+ }
179
+ } catch (symlinkErr) {
180
+ const error = symlinkErr as NodeJS.ErrnoException;
181
+ if (isWindows && error.code === "EPERM") {
182
+ console.log(chalk.red(` Permission denied creating symlink.`));
183
+ console.log(chalk.dim(" On Windows, enable Developer Mode or run as Administrator."));
184
+ console.log(chalk.dim(" Settings > Update & Security > For developers > Developer Mode"));
185
+ }
186
+ throw symlinkErr;
187
+ }
188
+ result.created.push(entry.dest);
58
189
 
59
- if (verbose) {
60
- console.log(chalk.dim(` Linked: ${entry.dest} → ${entry.src}`));
190
+ if (verbose) {
191
+ console.log(chalk.dim(` Linked: ${entry.dest} → ${entry.src}`));
192
+ }
61
193
  }
62
194
  } catch (err) {
63
- const msg = `Failed to link ${entry.dest}: ${(err as Error).message}`;
195
+ const error = err as NodeJS.ErrnoException;
196
+ const msg = `Failed to install ${entry.dest}: ${formatPermissionError(error, join(baseDir, entry.dest))}`;
64
197
  result.errors.push(msg);
65
198
  if (verbose) {
66
199
  console.log(chalk.red(` ✗ ${msg}`));
200
+ if (error.code === "EACCES" || error.code === "EPERM") {
201
+ console.log(chalk.dim(" Check directory permissions or run with appropriate privileges."));
202
+ }
67
203
  }
68
204
  }
69
205
  }
70
206
 
207
+ // If enabledFeatures provided and plugin has a runtime.json entry, update it
208
+ if (enabledFeatures !== undefined) {
209
+ const runtimeEntry = installEntries.find((e) => e.copy && e.dest.endsWith("runtime.json"));
210
+ if (runtimeEntry) {
211
+ const runtimePath = join(baseDir, runtimeEntry.dest);
212
+ await writeRuntimeConfig(runtimePath, { features: enabledFeatures }, verbose);
213
+ }
214
+ }
215
+
71
216
  return result;
72
217
  }
73
218
 
74
219
  /**
75
- * Remove symlinks for a plugin's omp.install entries
220
+ * Read runtime.json config from a plugin's installed location
221
+ * Returns {} on failure so callers can detect missing/corrupt config and fall back to defaults
222
+ */
223
+ export function readRuntimeConfig(runtimePath: string): PluginRuntimeConfig {
224
+ try {
225
+ const content = readFileSync(runtimePath, "utf-8");
226
+ return JSON.parse(content) as PluginRuntimeConfig;
227
+ } catch {
228
+ // Return empty object (not {features: []}) so callers detect missing config
229
+ // and can fall back to plugin defaults instead of treating as "all disabled"
230
+ return {};
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Write runtime.json config to a plugin's installed location
236
+ */
237
+ export async function writeRuntimeConfig(
238
+ runtimePath: string,
239
+ config: PluginRuntimeConfig,
240
+ verbose = false,
241
+ ): Promise<void> {
242
+ try {
243
+ const existing = readRuntimeConfig(runtimePath);
244
+ const merged: PluginRuntimeConfig = {
245
+ features: config.features ?? existing.features ?? [],
246
+ options: { ...existing.options, ...config.options },
247
+ };
248
+ writeFileSync(runtimePath, JSON.stringify(merged, null, 2) + "\n");
249
+ if (verbose) {
250
+ console.log(chalk.dim(` Updated: ${runtimePath}`));
251
+ }
252
+ } catch (err) {
253
+ if (verbose) {
254
+ console.log(chalk.yellow(` ⚠ Failed to update runtime config: ${err}`));
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Get the path to a plugin's runtime.json in the installed location
261
+ */
262
+ export function getRuntimeConfigPath(pkgJson: PluginPackageJson, global = true): string | null {
263
+ const entries = getInstallEntries(pkgJson);
264
+ const runtimeEntry = entries.find((e) => e.copy && e.dest.endsWith("runtime.json"));
265
+ if (!runtimeEntry) return null;
266
+ return join(getBaseDir(global), runtimeEntry.dest);
267
+ }
268
+
269
+ /**
270
+ * Remove symlinks and copied files for a plugin's omp.install entries
76
271
  */
77
272
  export async function removePluginSymlinks(
78
273
  _pluginName: string,
79
274
  pkgJson: PluginPackageJson,
275
+ global = true,
80
276
  verbose = true,
81
- ): Promise<SymlinkResult> {
82
- const result: SymlinkResult = { created: [], errors: [] };
277
+ ): Promise<SymlinkRemovalResult> {
278
+ const result: SymlinkRemovalResult = { removed: [], errors: [], skippedNonSymlinks: [] };
83
279
 
84
- if (!pkgJson.omp?.install?.length) {
280
+ const installEntries = getInstallEntries(pkgJson);
281
+ if (installEntries.length === 0) {
85
282
  return result;
86
283
  }
87
284
 
88
- for (const entry of pkgJson.omp.install) {
89
- const dest = join(PI_CONFIG_DIR, entry.dest);
285
+ const baseDir = getBaseDir(global);
286
+
287
+ for (const entry of installEntries) {
288
+ // Validate dest path stays within base directory (prevents path traversal attacks)
289
+ if (!isPathWithinBase(baseDir, entry.dest)) {
290
+ const msg = `Path traversal blocked: ${entry.dest} escapes base directory`;
291
+ result.errors.push(msg);
292
+ if (verbose) {
293
+ console.log(chalk.red(` ✗ ${msg}`));
294
+ }
295
+ continue;
296
+ }
297
+
298
+ const dest = join(baseDir, entry.dest);
90
299
 
91
300
  try {
92
301
  if (existsSync(dest)) {
302
+ const stats = lstatSync(dest);
303
+
304
+ // For copy entries (like runtime.json), we can safely remove them
305
+ if (entry.copy) {
306
+ await rm(dest, { force: true });
307
+ result.removed.push(entry.dest);
308
+ if (verbose) {
309
+ console.log(chalk.dim(` Removed: ${entry.dest}`));
310
+ }
311
+ continue;
312
+ }
313
+
314
+ // For symlinks, check they're actually symlinks
315
+ if (!stats.isSymbolicLink()) {
316
+ result.skippedNonSymlinks.push(dest);
317
+ if (verbose) {
318
+ console.log(chalk.yellow(` ⚠ Skipping ${entry.dest}: not a symlink (may contain user data)`));
319
+ }
320
+ continue;
321
+ }
322
+
93
323
  await rm(dest, { force: true, recursive: true });
94
- result.created.push(entry.dest);
324
+ result.removed.push(entry.dest);
95
325
  if (verbose) {
96
326
  console.log(chalk.dim(` Removed: ${entry.dest}`));
97
327
  }
98
328
  }
99
329
  } catch (err) {
100
- const msg = `Failed to remove ${entry.dest}: ${(err as Error).message}`;
330
+ const error = err as NodeJS.ErrnoException;
331
+ const msg = `Failed to remove ${entry.dest}: ${formatPermissionError(error, dest)}`;
101
332
  result.errors.push(msg);
102
333
  if (verbose) {
103
334
  console.log(chalk.yellow(` ⚠ ${msg}`));
335
+ if (error.code === "EACCES" || error.code === "EPERM") {
336
+ console.log(chalk.dim(" Check directory permissions or run with appropriate privileges."));
337
+ }
104
338
  }
105
339
  }
106
340
  }
@@ -109,7 +343,7 @@ export async function removePluginSymlinks(
109
343
  }
110
344
 
111
345
  /**
112
- * Check symlink health for a plugin
346
+ * Check symlink/file health for a plugin
113
347
  */
114
348
  export async function checkPluginSymlinks(
115
349
  pluginName: string,
@@ -118,14 +352,22 @@ export async function checkPluginSymlinks(
118
352
  ): Promise<{ valid: string[]; broken: string[]; missing: string[] }> {
119
353
  const result = { valid: [] as string[], broken: [] as string[], missing: [] as string[] };
120
354
  const sourceDir = getPluginSourceDir(pluginName, global);
355
+ const baseDir = getBaseDir(global);
121
356
 
122
- if (!pkgJson.omp?.install?.length) {
357
+ const installEntries = getInstallEntries(pkgJson);
358
+ if (installEntries.length === 0) {
123
359
  return result;
124
360
  }
125
361
 
126
- for (const entry of pkgJson.omp.install) {
362
+ for (const entry of installEntries) {
363
+ // Skip entries with path traversal (treat as broken)
364
+ if (!isPathWithinBase(baseDir, entry.dest)) {
365
+ result.broken.push(entry.dest);
366
+ continue;
367
+ }
368
+
127
369
  const src = join(sourceDir, entry.src);
128
- const dest = join(PI_CONFIG_DIR, entry.dest);
370
+ const dest = join(baseDir, entry.dest);
129
371
 
130
372
  if (!existsSync(dest)) {
131
373
  result.missing.push(entry.dest);
@@ -134,6 +376,18 @@ export async function checkPluginSymlinks(
134
376
 
135
377
  try {
136
378
  const stats = lstatSync(dest);
379
+
380
+ // For copy entries, just check the file exists
381
+ if (entry.copy) {
382
+ if (stats.isFile()) {
383
+ result.valid.push(entry.dest);
384
+ } else {
385
+ result.broken.push(entry.dest);
386
+ }
387
+ continue;
388
+ }
389
+
390
+ // For symlinks, verify they point to valid sources
137
391
  if (stats.isSymbolicLink()) {
138
392
  const _target = await readlink(dest);
139
393
  if (existsSync(src)) {
@@ -178,11 +432,13 @@ export async function getPluginForSymlink(
178
432
  export async function traceInstalledFile(
179
433
  filePath: string,
180
434
  installedPlugins: Map<string, PluginPackageJson>,
435
+ global = true,
181
436
  ): Promise<{ plugin: string; entry: OmpInstallEntry } | null> {
182
- // Normalize the path relative to PI_CONFIG_DIR
437
+ // Normalize the path relative to the base directory
438
+ const baseDir = getBaseDir(global);
183
439
  let relativePath = filePath;
184
- if (filePath.startsWith(PI_CONFIG_DIR)) {
185
- relativePath = filePath.slice(PI_CONFIG_DIR.length + 1);
440
+ if (filePath.startsWith(baseDir)) {
441
+ relativePath = filePath.slice(baseDir.length + 1);
186
442
  }
187
443
 
188
444
  for (const [name, pkgJson] of installedPlugins) {
package/tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
- "module": "Node16",
4
+ "module": "ESNext",
5
5
  "lib": ["ES2022"],
6
6
  "strict": true,
7
7
  "esModuleInterop": true,
@@ -12,12 +12,16 @@
12
12
  "sourceMap": true,
13
13
  "inlineSources": true,
14
14
  "inlineSourceMap": false,
15
- "moduleResolution": "Node16",
15
+ "moduleResolution": "bundler",
16
16
  "resolveJsonModule": true,
17
17
  "allowImportingTsExtensions": false,
18
18
  "outDir": "./dist",
19
19
  "rootDir": "./src",
20
- "types": ["node", "bun-types"]
20
+ "types": ["node", "bun-types"],
21
+ "baseUrl": ".",
22
+ "paths": {
23
+ "@omp/*": ["src/*"]
24
+ }
21
25
  },
22
26
  "include": ["src/**/*"],
23
27
  "exclude": ["node_modules", "dist"]