@oh-my-pi/cli 0.4.0 → 0.5.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 (108) hide show
  1. package/README.md +14 -13
  2. package/dist/cli.js +4787 -858
  3. package/dist/commands/config.d.ts +27 -0
  4. package/dist/commands/config.d.ts.map +1 -1
  5. package/dist/commands/create.d.ts.map +1 -1
  6. package/dist/commands/doctor.d.ts +2 -0
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/env.d.ts.map +1 -1
  9. package/dist/commands/features.d.ts.map +1 -1
  10. package/dist/commands/info.d.ts.map +1 -1
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/install.d.ts +6 -0
  13. package/dist/commands/install.d.ts.map +1 -1
  14. package/dist/commands/link.d.ts +1 -0
  15. package/dist/commands/link.d.ts.map +1 -1
  16. package/dist/commands/list.d.ts.map +1 -1
  17. package/dist/commands/outdated.d.ts.map +1 -1
  18. package/dist/commands/search.d.ts.map +1 -1
  19. package/dist/commands/uninstall.d.ts +3 -0
  20. package/dist/commands/uninstall.d.ts.map +1 -1
  21. package/dist/commands/update.d.ts +1 -0
  22. package/dist/commands/update.d.ts.map +1 -1
  23. package/dist/commands/why.d.ts.map +1 -1
  24. package/dist/conflicts.d.ts +7 -2
  25. package/dist/conflicts.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/lock.d.ts.map +1 -1
  29. package/dist/lockfile.d.ts +24 -3
  30. package/dist/lockfile.d.ts.map +1 -1
  31. package/dist/manifest.d.ts +12 -1
  32. package/dist/manifest.d.ts.map +1 -1
  33. package/dist/npm.d.ts +11 -0
  34. package/dist/npm.d.ts.map +1 -1
  35. package/dist/output.d.ts +51 -0
  36. package/dist/output.d.ts.map +1 -0
  37. package/dist/paths.d.ts +5 -0
  38. package/dist/paths.d.ts.map +1 -1
  39. package/dist/progress.d.ts +78 -0
  40. package/dist/progress.d.ts.map +1 -0
  41. package/dist/symlinks.d.ts.map +1 -1
  42. package/package.json +14 -7
  43. package/.editorconfig +0 -14
  44. package/.github/icon.png +0 -0
  45. package/.github/logo.png +0 -0
  46. package/.github/workflows/ci.yml +0 -32
  47. package/.github/workflows/publish.yml +0 -42
  48. package/.prettierrc +0 -6
  49. package/biome.json +0 -29
  50. package/bun.lock +0 -112
  51. package/plugins/exa/README.md +0 -159
  52. package/plugins/exa/package.json +0 -89
  53. package/plugins/exa/tools/exa/company.ts +0 -46
  54. package/plugins/exa/tools/exa/index.ts +0 -75
  55. package/plugins/exa/tools/exa/linkedin.ts +0 -46
  56. package/plugins/exa/tools/exa/researcher.ts +0 -48
  57. package/plugins/exa/tools/exa/runtime.json +0 -4
  58. package/plugins/exa/tools/exa/search.ts +0 -57
  59. package/plugins/exa/tools/exa/shared.ts +0 -256
  60. package/plugins/exa/tools/exa/websets.ts +0 -73
  61. package/plugins/metal-theme/README.md +0 -13
  62. package/plugins/metal-theme/omp.json +0 -8
  63. package/plugins/metal-theme/package.json +0 -28
  64. package/plugins/metal-theme/themes/metal.json +0 -79
  65. package/plugins/subagents/README.md +0 -28
  66. package/plugins/subagents/agents/explore.md +0 -82
  67. package/plugins/subagents/agents/planner.md +0 -54
  68. package/plugins/subagents/agents/reviewer.md +0 -59
  69. package/plugins/subagents/agents/task.md +0 -53
  70. package/plugins/subagents/commands/architect-plan.md +0 -10
  71. package/plugins/subagents/commands/implement-with-critic.md +0 -11
  72. package/plugins/subagents/commands/implement.md +0 -11
  73. package/plugins/subagents/omp.json +0 -15
  74. package/plugins/subagents/package.json +0 -58
  75. package/plugins/subagents/tools/task/index.ts +0 -1247
  76. package/plugins/user-prompt/README.md +0 -86
  77. package/plugins/user-prompt/package.json +0 -30
  78. package/plugins/user-prompt/tools/user-prompt/index.ts +0 -263
  79. package/scripts/bump-version.sh +0 -49
  80. package/scripts/publish.sh +0 -35
  81. package/src/cli.ts +0 -242
  82. package/src/commands/config.ts +0 -399
  83. package/src/commands/create.ts +0 -203
  84. package/src/commands/doctor.ts +0 -305
  85. package/src/commands/enable.ts +0 -122
  86. package/src/commands/env.ts +0 -38
  87. package/src/commands/features.ts +0 -332
  88. package/src/commands/info.ts +0 -120
  89. package/src/commands/init.ts +0 -60
  90. package/src/commands/install.ts +0 -767
  91. package/src/commands/link.ts +0 -159
  92. package/src/commands/list.ts +0 -197
  93. package/src/commands/outdated.ts +0 -87
  94. package/src/commands/search.ts +0 -77
  95. package/src/commands/uninstall.ts +0 -127
  96. package/src/commands/update.ts +0 -175
  97. package/src/commands/why.ts +0 -136
  98. package/src/conflicts.ts +0 -116
  99. package/src/errors.ts +0 -22
  100. package/src/index.ts +0 -46
  101. package/src/lock.ts +0 -46
  102. package/src/lockfile.ts +0 -132
  103. package/src/manifest.ts +0 -360
  104. package/src/npm.ts +0 -206
  105. package/src/paths.ts +0 -137
  106. package/src/runtime.ts +0 -109
  107. package/src/symlinks.ts +0 -460
  108. package/tsconfig.json +0 -28
@@ -1,767 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { cp, mkdir, readFile, rm } from "node:fs/promises";
4
- import { basename, join, resolve } from "node:path";
5
- import { createInterface } from "node:readline";
6
- import { type Conflict, detectConflicts, detectIntraPluginDuplicates, formatConflicts } from "@omp/conflicts";
7
- import { updateLockFile } from "@omp/lockfile";
8
- import {
9
- getInstalledPlugins,
10
- initGlobalPlugins,
11
- initProjectPlugins,
12
- loadPluginsJson,
13
- type PluginConfig,
14
- type PluginPackageJson,
15
- readPluginPackageJson,
16
- savePluginsJson,
17
- } from "@omp/manifest";
18
- import { npmInfo, npmInstall } from "@omp/npm";
19
- import {
20
- NODE_MODULES_DIR,
21
- PI_CONFIG_DIR,
22
- PLUGINS_DIR,
23
- PROJECT_NODE_MODULES,
24
- PROJECT_PI_DIR,
25
- resolveScope,
26
- } from "@omp/paths";
27
- import { createPluginSymlinks, getAllFeatureNames, getDefaultFeatures } from "@omp/symlinks";
28
- import chalk from "chalk";
29
-
30
- /**
31
- * Parsed package specifier with optional features
32
- */
33
- export interface ParsedPackageSpec {
34
- name: string;
35
- version: string;
36
- /** null = not specified, [] = explicit empty, string[] = specific features */
37
- features: string[] | null;
38
- /** true if [*] was used */
39
- allFeatures: boolean;
40
- }
41
-
42
- /**
43
- * Parse package specifier with optional features bracket syntax.
44
- * Examples:
45
- * "exa" -> { name: "exa", version: "latest", features: null }
46
- * "exa@^1.0" -> { name: "exa", version: "^1.0", features: null }
47
- * "exa[search]" -> { name: "exa", version: "latest", features: ["search"] }
48
- * "exa[search,websets]@^1.0" -> { name: "exa", version: "^1.0", features: ["search", "websets"] }
49
- * "@scope/exa[*]" -> { name: "@scope/exa", version: "latest", allFeatures: true }
50
- * "exa[]" -> { name: "exa", version: "latest", features: [] } (no optional features)
51
- */
52
- export function parsePackageSpecWithFeatures(spec: string): ParsedPackageSpec {
53
- // Regex breakdown:
54
- // ^(@?[^@[\]]+) - Capture name (optionally scoped with @, no @ [ or ] in name)
55
- // (?:\[([^\]]*)\])? - Optionally capture features inside []
56
- // (?:@(.+))?$ - Optionally capture version after @
57
- const match = spec.match(/^(@?[^@[\]]+)(?:\[([^\]]*)\])?(?:@(.+))?$/);
58
-
59
- if (!match) {
60
- // Fallback: treat as plain name
61
- return {
62
- name: spec,
63
- version: "latest",
64
- features: null,
65
- allFeatures: false,
66
- };
67
- }
68
-
69
- const [, name, featuresStr, version = "latest"] = match;
70
-
71
- // No bracket at all
72
- if (featuresStr === undefined) {
73
- return { name, version, features: null, allFeatures: false };
74
- }
75
-
76
- // [*] = all features
77
- if (featuresStr === "*") {
78
- return { name, version, features: null, allFeatures: true };
79
- }
80
-
81
- // [] = explicit empty (no optional features, core only)
82
- if (featuresStr === "") {
83
- return { name, version, features: [], allFeatures: false };
84
- }
85
-
86
- // [f1,f2,...] = specific features
87
- const features = featuresStr
88
- .split(",")
89
- .map((f) => f.trim())
90
- .filter(Boolean);
91
- return { name, version, features, allFeatures: false };
92
- }
93
-
94
- /**
95
- * Resolve which features to enable based on user request, existing config, and plugin defaults.
96
- *
97
- * Resolution order:
98
- * 1. User explicitly requested [*] -> all features
99
- * 2. User explicitly specified [f1,f2] -> exactly those features
100
- * 3. Reinstall with no bracket -> preserve existing selection
101
- * 4. First install with no bracket -> ALL features
102
- */
103
- export function resolveFeatures(
104
- pkgJson: PluginPackageJson,
105
- requested: ParsedPackageSpec,
106
- existingConfig: PluginConfig | undefined,
107
- isReinstall: boolean,
108
- ): { enabledFeatures: string[]; configToStore: PluginConfig | undefined } {
109
- const pluginFeatures = pkgJson.omp?.features || {};
110
- const allFeatureNames = Object.keys(pluginFeatures);
111
-
112
- // No features defined in plugin -> nothing to configure
113
- if (allFeatureNames.length === 0) {
114
- return { enabledFeatures: [], configToStore: undefined };
115
- }
116
-
117
- // Case 1: User explicitly requested [*] -> all features
118
- if (requested.allFeatures) {
119
- return {
120
- enabledFeatures: allFeatureNames,
121
- configToStore: { features: ["*"] },
122
- };
123
- }
124
-
125
- // Case 2: User explicitly specified features -> use exactly those
126
- if (requested.features !== null) {
127
- // Validate requested features exist
128
- for (const f of requested.features) {
129
- if (!pluginFeatures[f]) {
130
- throw new Error(`Unknown feature "${f}". Available: ${allFeatureNames.join(", ")}`);
131
- }
132
- }
133
- return {
134
- enabledFeatures: requested.features,
135
- configToStore: { features: requested.features },
136
- };
137
- }
138
-
139
- // Case 3: Reinstall with no bracket -> preserve existing config
140
- if (isReinstall && existingConfig?.features !== undefined) {
141
- const storedFeatures = existingConfig.features;
142
-
143
- // null means "first install, used defaults" - recompute defaults in case plugin updated
144
- if (storedFeatures === null) {
145
- return {
146
- enabledFeatures: getDefaultFeatures(pluginFeatures),
147
- configToStore: undefined, // Keep existing
148
- };
149
- }
150
-
151
- // ["*"] means explicitly all
152
- if (Array.isArray(storedFeatures) && storedFeatures.includes("*")) {
153
- return {
154
- enabledFeatures: allFeatureNames,
155
- configToStore: undefined,
156
- };
157
- }
158
-
159
- // Specific features
160
- return {
161
- enabledFeatures: storedFeatures as string[],
162
- configToStore: undefined,
163
- };
164
- }
165
-
166
- // Case 4: First install with no bracket -> use DEFAULT features
167
- if (!isReinstall) {
168
- const defaultFeatures = getDefaultFeatures(pluginFeatures);
169
- return {
170
- enabledFeatures: defaultFeatures,
171
- configToStore: { features: null }, // null = "first install, used defaults"
172
- };
173
- }
174
-
175
- // Case 5: Reinstall, no existing config -> use defaults
176
- return {
177
- enabledFeatures: getDefaultFeatures(pluginFeatures),
178
- configToStore: undefined,
179
- };
180
- }
181
-
182
- /**
183
- * Process omp dependencies recursively with cycle detection.
184
- * Creates symlinks for dependencies that have omp.install entries.
185
- */
186
- async function processOmpDependencies(pkgJson: PluginPackageJson, isGlobal: boolean, seen: Set<string>): Promise<void> {
187
- if (!pkgJson.dependencies) return;
188
-
189
- for (const depName of Object.keys(pkgJson.dependencies)) {
190
- if (seen.has(depName)) {
191
- console.log(chalk.yellow(` Skipping circular dependency: ${depName}`));
192
- continue;
193
- }
194
- seen.add(depName);
195
-
196
- const depPkgJson = await readPluginPackageJson(depName, isGlobal);
197
- if (depPkgJson?.omp?.install) {
198
- console.log(chalk.dim(` Processing dependency: ${depName}`));
199
- await createPluginSymlinks(depName, depPkgJson, isGlobal);
200
- // Recurse into this dependency's dependencies
201
- await processOmpDependencies(depPkgJson, isGlobal, seen);
202
- }
203
- }
204
- }
205
-
206
- export interface InstallOptions {
207
- global?: boolean;
208
- local?: boolean;
209
- save?: boolean;
210
- saveDev?: boolean;
211
- force?: boolean;
212
- json?: boolean;
213
- }
214
-
215
- /**
216
- * Prompt user to choose when there's a conflict
217
- */
218
- async function promptConflictResolution(conflict: Conflict): Promise<number | null> {
219
- const rl = createInterface({
220
- input: process.stdin,
221
- output: process.stdout,
222
- });
223
-
224
- return new Promise((resolve) => {
225
- console.log(chalk.yellow(`\n⚠ Conflict: ${formatConflicts([conflict])[0]}`));
226
- conflict.plugins.forEach((p, i) => {
227
- console.log(` [${i + 1}] ${p.name}`);
228
- });
229
- console.log(` [${conflict.plugins.length + 1}] abort`);
230
-
231
- rl.question(" Choose: ", (answer) => {
232
- rl.close();
233
- const choice = parseInt(answer, 10);
234
- if (choice > 0 && choice <= conflict.plugins.length) {
235
- resolve(choice - 1);
236
- } else {
237
- resolve(null);
238
- }
239
- });
240
- });
241
- }
242
-
243
- /**
244
- * Check if a path looks like a local path
245
- */
246
- function isLocalPath(spec: string): boolean {
247
- return spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("~");
248
- }
249
-
250
- /**
251
- * Install plugins from package specifiers
252
- * omp install [pkg...]
253
- */
254
- export async function installPlugin(packages?: string[], options: InstallOptions = {}): Promise<void> {
255
- const isGlobal = resolveScope(options);
256
- const prefix = isGlobal ? PLUGINS_DIR : ".pi";
257
- const _nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
258
-
259
- // Initialize plugins directory if needed
260
- if (isGlobal) {
261
- await initGlobalPlugins();
262
- } else {
263
- // Initialize project .pi directory with both plugins.json and package.json
264
- await initProjectPlugins();
265
- }
266
-
267
- // If no packages specified, install from plugins.json
268
- if (!packages || packages.length === 0) {
269
- const pluginsJson = await loadPluginsJson(isGlobal);
270
- // Prefer locked versions for reproducible installs
271
- const lockFile = await import("@omp/lockfile").then((m) => m.loadLockFile(isGlobal));
272
- packages = await Promise.all(
273
- Object.entries(pluginsJson.plugins).map(async ([name, version]) => {
274
- // Use locked version if available for reproducibility
275
- const lockedVersion = lockFile?.packages[name]?.version;
276
- return `${name}@${lockedVersion || version}`;
277
- }),
278
- );
279
-
280
- if (packages.length === 0) {
281
- console.log(chalk.yellow("No plugins to install."));
282
- console.log(
283
- chalk.dim(isGlobal ? "Add plugins with: omp install <package>" : "Add plugins to .pi/plugins.json"),
284
- );
285
- process.exitCode = 1;
286
- return;
287
- }
288
-
289
- console.log(
290
- chalk.blue(`Installing ${packages.length} plugin(s) from ${isGlobal ? "package.json" : "plugins.json"}...`),
291
- );
292
- }
293
-
294
- // Get existing plugins for conflict detection
295
- const existingPlugins = await getInstalledPlugins(isGlobal);
296
-
297
- const results: Array<{
298
- name: string;
299
- version: string;
300
- success: boolean;
301
- error?: string;
302
- }> = [];
303
-
304
- // Load plugins.json once for reinstall detection and config storage
305
- let pluginsJson = await loadPluginsJson(isGlobal);
306
-
307
- for (const spec of packages) {
308
- // Check if it's a local path
309
- if (isLocalPath(spec)) {
310
- const result = await installLocalPlugin(spec, isGlobal, options);
311
- results.push(result);
312
- continue;
313
- }
314
-
315
- const parsed = parsePackageSpecWithFeatures(spec);
316
- const { name, version } = parsed;
317
- const pkgSpec = version === "latest" ? name : `${name}@${version}`;
318
-
319
- // Track installation state for rollback
320
- let npmInstallSucceeded = false;
321
- let createdSymlinks: string[] = [];
322
- let resolvedVersion = version;
323
-
324
- // Check if this is a reinstall (plugin already exists)
325
- const isReinstall = existingPlugins.has(name) || !!pluginsJson.plugins[name];
326
- const existingConfig = pluginsJson.config?.[name];
327
-
328
- try {
329
- console.log(chalk.blue(`\nInstalling ${pkgSpec}...`));
330
-
331
- // 1. Resolve version and fetch package metadata from npm registry
332
- // npm info includes omp field if present in package.json
333
- const info = await npmInfo(pkgSpec);
334
- if (!info) {
335
- console.log(chalk.red(` ✗ Package not found: ${name}`));
336
- process.exitCode = 1;
337
- results.push({
338
- name,
339
- version,
340
- success: false,
341
- error: "Package not found",
342
- });
343
- continue;
344
- }
345
- resolvedVersion = info.version;
346
-
347
- // 2. Check for conflicts BEFORE npm install using registry metadata
348
- const skipDestinations = new Set<string>();
349
- const preInstallPkgJson = info.omp?.install ? { name: info.name, version: info.version, omp: info.omp } : null;
350
-
351
- if (preInstallPkgJson) {
352
- // Check for intra-plugin duplicates first
353
- const intraDupes = detectIntraPluginDuplicates(preInstallPkgJson);
354
- if (intraDupes.length > 0) {
355
- console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
356
- for (const dupe of intraDupes) {
357
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
358
- }
359
- process.exitCode = 1;
360
- results.push({
361
- name,
362
- version: info.version,
363
- success: false,
364
- error: "Duplicate destinations in plugin",
365
- });
366
- continue;
367
- }
368
-
369
- const preInstallConflicts = detectConflicts(name, preInstallPkgJson, existingPlugins);
370
-
371
- if (preInstallConflicts.length > 0 && !options.force) {
372
- // Check for non-interactive terminal (CI environments)
373
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
374
- console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
375
- for (const conflict of preInstallConflicts) {
376
- console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
377
- }
378
- process.exitCode = 1;
379
- results.push({
380
- name,
381
- version: info.version,
382
- success: false,
383
- error: "Conflicts in non-interactive mode",
384
- });
385
- continue;
386
- }
387
-
388
- // Handle conflicts BEFORE downloading the package
389
- let abort = false;
390
- for (const conflict of preInstallConflicts) {
391
- const choice = await promptConflictResolution(conflict);
392
- if (choice === null) {
393
- abort = true;
394
- break;
395
- }
396
- // choice is 0-indexed: 0 = first plugin (existing), last index = new plugin
397
- const newPluginIndex = conflict.plugins.length - 1;
398
- if (choice !== newPluginIndex) {
399
- // User chose an existing plugin, skip this destination
400
- skipDestinations.add(conflict.dest);
401
- }
402
- }
403
-
404
- if (abort) {
405
- console.log(chalk.yellow(` Aborted due to conflicts (before download)`));
406
- process.exitCode = 1;
407
- results.push({
408
- name,
409
- version: info.version,
410
- success: false,
411
- error: "Conflicts",
412
- });
413
- continue;
414
- }
415
- }
416
- }
417
-
418
- // 3. npm install - only reached if no conflicts or user resolved them
419
- console.log(chalk.dim(` Fetching from npm...`));
420
- await npmInstall([pkgSpec], prefix, { save: options.save || isGlobal });
421
- npmInstallSucceeded = true;
422
-
423
- // 4. Read package.json from installed package
424
- const pkgJson = await readPluginPackageJson(name, isGlobal);
425
- if (!pkgJson) {
426
- console.log(chalk.yellow(` ⚠ Installed but no package.json found`));
427
- results.push({ name, version: info.version, success: true });
428
- continue;
429
- }
430
-
431
- // 5. Re-check conflicts with full package.json if we didn't check pre-install
432
- // This handles edge cases where omp field wasn't in registry metadata
433
- if (!preInstallPkgJson) {
434
- // Check for intra-plugin duplicates first
435
- const intraDupes = detectIntraPluginDuplicates(pkgJson);
436
- if (intraDupes.length > 0) {
437
- console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
438
- for (const dupe of intraDupes) {
439
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
440
- }
441
- // Rollback: uninstall the package
442
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], {
443
- stdio: "pipe",
444
- });
445
- process.exitCode = 1;
446
- results.push({
447
- name,
448
- version: info.version,
449
- success: false,
450
- error: "Duplicate destinations in plugin",
451
- });
452
- continue;
453
- }
454
-
455
- const conflicts = detectConflicts(name, pkgJson, existingPlugins);
456
-
457
- if (conflicts.length > 0 && !options.force) {
458
- // Check for non-interactive terminal (CI environments)
459
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
460
- console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
461
- for (const conflict of conflicts) {
462
- console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
463
- }
464
- // Rollback: uninstall the package
465
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], {
466
- stdio: "pipe",
467
- });
468
- process.exitCode = 1;
469
- results.push({
470
- name,
471
- version: info.version,
472
- success: false,
473
- error: "Conflicts in non-interactive mode",
474
- });
475
- continue;
476
- }
477
-
478
- let abort = false;
479
- for (const conflict of conflicts) {
480
- const choice = await promptConflictResolution(conflict);
481
- if (choice === null) {
482
- abort = true;
483
- break;
484
- }
485
- const newPluginIndex = conflict.plugins.length - 1;
486
- if (choice !== newPluginIndex) {
487
- skipDestinations.add(conflict.dest);
488
- }
489
- }
490
-
491
- if (abort) {
492
- console.log(chalk.yellow(` Aborted due to conflicts`));
493
- // Rollback: uninstall the package
494
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], {
495
- stdio: "pipe",
496
- });
497
- process.exitCode = 1;
498
- results.push({
499
- name,
500
- version: info.version,
501
- success: false,
502
- error: "Conflicts",
503
- });
504
- continue;
505
- }
506
- }
507
- }
508
-
509
- // 6. Resolve features and create symlinks
510
- const { enabledFeatures, configToStore } = resolveFeatures(pkgJson, parsed, existingConfig, isReinstall);
511
-
512
- // Log feature selection if plugin has features
513
- const allFeatureNames = getAllFeatureNames(pkgJson);
514
- if (allFeatureNames.length > 0) {
515
- if (enabledFeatures.length === allFeatureNames.length) {
516
- console.log(chalk.dim(` Features: all (${enabledFeatures.join(", ")})`));
517
- } else if (enabledFeatures.length === 0) {
518
- console.log(chalk.dim(` Features: none (core only)`));
519
- } else {
520
- console.log(chalk.dim(` Features: ${enabledFeatures.join(", ")}`));
521
- }
522
- }
523
-
524
- // Create symlinks for omp.install entries (skip destinations user assigned to existing plugins)
525
- const symlinkResult = await createPluginSymlinks(
526
- name,
527
- pkgJson,
528
- isGlobal,
529
- true,
530
- skipDestinations,
531
- enabledFeatures,
532
- );
533
- createdSymlinks = symlinkResult.created;
534
-
535
- // 7. Process dependencies with omp field (with cycle detection)
536
- await processOmpDependencies(pkgJson, isGlobal, new Set([name]));
537
-
538
- // 8. Update manifest and config
539
- // For global mode, npm --save already updates package.json dependencies
540
- // but we need to handle devDependencies and config manually
541
- // For project-local mode, we must manually update plugins.json
542
- if (options.save || options.saveDev || configToStore) {
543
- // Reload to avoid stale data if multiple packages are being installed
544
- pluginsJson = await loadPluginsJson(isGlobal);
545
- if (options.saveDev) {
546
- // Save to devDependencies
547
- if (!pluginsJson.devDependencies) {
548
- pluginsJson.devDependencies = {};
549
- }
550
- pluginsJson.devDependencies[name] = info.version;
551
- // Remove from plugins if it was there
552
- delete pluginsJson.plugins[name];
553
- } else if (!isGlobal) {
554
- // Save to plugins (project-local mode only - npm handles global)
555
- pluginsJson.plugins[name] = info.version;
556
- }
557
-
558
- // Store feature config if changed
559
- if (configToStore) {
560
- if (!pluginsJson.config) {
561
- pluginsJson.config = {};
562
- }
563
- pluginsJson.config[name] = {
564
- ...pluginsJson.config[name],
565
- ...configToStore,
566
- };
567
- }
568
-
569
- await savePluginsJson(pluginsJson, isGlobal);
570
- }
571
-
572
- // Add to installed plugins map for subsequent conflict detection
573
- existingPlugins.set(name, pkgJson);
574
-
575
- // Update lock file with exact version
576
- await updateLockFile(name, info.version, isGlobal);
577
-
578
- console.log(chalk.green(`✓ Installed ${name}@${info.version}`));
579
- results.push({ name, version: info.version, success: true });
580
- } catch (err) {
581
- const errorMsg = (err as Error).message;
582
- console.log(chalk.red(` ✗ Failed to install ${name}: ${errorMsg}`));
583
-
584
- // Rollback: remove any symlinks that were created
585
- if (createdSymlinks.length > 0) {
586
- console.log(chalk.dim(" Rolling back symlinks..."));
587
- const baseDir = isGlobal ? PI_CONFIG_DIR : PROJECT_PI_DIR;
588
- for (const dest of createdSymlinks) {
589
- try {
590
- await rm(join(baseDir, dest), { force: true, recursive: true });
591
- } catch {
592
- // Ignore cleanup errors
593
- }
594
- }
595
- }
596
-
597
- // Rollback: uninstall npm package if it was installed
598
- if (npmInstallSucceeded) {
599
- console.log(chalk.dim(" Rolling back npm install..."));
600
- try {
601
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], {
602
- stdio: "pipe",
603
- });
604
- } catch {
605
- // Ignore cleanup errors
606
- }
607
- }
608
-
609
- process.exitCode = 1;
610
- results.push({
611
- name,
612
- version: resolvedVersion,
613
- success: false,
614
- error: errorMsg,
615
- });
616
- }
617
- }
618
-
619
- // Summary
620
- const successful = results.filter((r) => r.success);
621
- const failed = results.filter((r) => !r.success);
622
-
623
- console.log();
624
- if (successful.length > 0) {
625
- console.log(chalk.green(`✓ Installed ${successful.length} plugin(s)`));
626
- }
627
- if (failed.length > 0) {
628
- console.log(chalk.red(`✗ Failed to install ${failed.length} plugin(s)`));
629
- process.exitCode = 1;
630
- }
631
-
632
- if (options.json) {
633
- console.log(JSON.stringify({ results }, null, 2));
634
- }
635
- }
636
-
637
- /**
638
- * Install a local plugin (copy or link based on path type)
639
- */
640
- async function installLocalPlugin(
641
- localPath: string,
642
- isGlobal: boolean,
643
- _options: InstallOptions,
644
- ): Promise<{
645
- name: string;
646
- version: string;
647
- success: boolean;
648
- error?: string;
649
- }> {
650
- // Expand ~ to home directory
651
- if (localPath.startsWith("~")) {
652
- localPath = join(process.env.HOME || "", localPath.slice(1));
653
- }
654
- localPath = resolve(localPath);
655
-
656
- if (!existsSync(localPath)) {
657
- console.log(chalk.red(`Error: Path does not exist: ${localPath}`));
658
- process.exitCode = 1;
659
- return {
660
- name: basename(localPath),
661
- version: "local",
662
- success: false,
663
- error: "Path not found",
664
- };
665
- }
666
-
667
- const _prefix = isGlobal ? PLUGINS_DIR : ".pi";
668
- const nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
669
-
670
- try {
671
- // Read package.json from local path
672
- const localPkgJsonPath = join(localPath, "package.json");
673
- let pkgJson: PluginPackageJson;
674
-
675
- if (existsSync(localPkgJsonPath)) {
676
- pkgJson = JSON.parse(await readFile(localPkgJsonPath, "utf-8"));
677
- } else {
678
- // Check for omp.json (legacy format)
679
- const ompJsonPath = join(localPath, "omp.json");
680
- if (existsSync(ompJsonPath)) {
681
- const ompJson = JSON.parse(await readFile(ompJsonPath, "utf-8"));
682
- // Convert omp.json to package.json format
683
- pkgJson = {
684
- name: ompJson.name || basename(localPath),
685
- version: ompJson.version || "0.0.0",
686
- description: ompJson.description,
687
- keywords: ["omp-plugin"],
688
- omp: {
689
- install: ompJson.install,
690
- },
691
- };
692
- } else {
693
- pkgJson = {
694
- name: basename(localPath),
695
- version: "0.0.0",
696
- keywords: ["omp-plugin"],
697
- };
698
- }
699
- }
700
-
701
- const pluginName = pkgJson.name;
702
- const pluginDir = join(nodeModules, pluginName);
703
-
704
- // Check for intra-plugin duplicates
705
- const intraDupes = detectIntraPluginDuplicates(pkgJson);
706
- if (intraDupes.length > 0) {
707
- console.log(chalk.red(`\nError: Plugin has duplicate destinations:`));
708
- for (const dupe of intraDupes) {
709
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
710
- }
711
- process.exitCode = 1;
712
- return {
713
- name: pluginName,
714
- version: pkgJson.version,
715
- success: false,
716
- error: "Duplicate destinations in plugin",
717
- };
718
- }
719
-
720
- console.log(chalk.blue(`\nInstalling ${pluginName} from ${localPath}...`));
721
-
722
- // Create node_modules directory
723
- await mkdir(nodeModules, { recursive: true });
724
-
725
- // Remove existing if present
726
- if (existsSync(pluginDir)) {
727
- await rm(pluginDir, { recursive: true, force: true });
728
- }
729
-
730
- // Copy the plugin
731
- await cp(localPath, pluginDir, { recursive: true });
732
- console.log(chalk.dim(` Copied to ${pluginDir}`));
733
-
734
- // Update plugins.json/package.json
735
- const pluginsJson = await loadPluginsJson(isGlobal);
736
- if (_options.saveDev) {
737
- if (!pluginsJson.devDependencies) {
738
- pluginsJson.devDependencies = {};
739
- }
740
- pluginsJson.devDependencies[pluginName] = `file:${localPath}`;
741
- // Remove from plugins if it was there
742
- delete pluginsJson.plugins[pluginName];
743
- } else {
744
- pluginsJson.plugins[pluginName] = `file:${localPath}`;
745
- }
746
- await savePluginsJson(pluginsJson, isGlobal);
747
-
748
- // Create symlinks
749
- await createPluginSymlinks(pluginName, pkgJson, isGlobal);
750
-
751
- // Update lock file for local plugin
752
- await updateLockFile(pluginName, pkgJson.version, isGlobal);
753
-
754
- console.log(chalk.green(`✓ Installed ${pluginName}@${pkgJson.version}`));
755
- return { name: pluginName, version: pkgJson.version, success: true };
756
- } catch (err) {
757
- const errorMsg = (err as Error).message;
758
- console.log(chalk.red(` ✗ Failed: ${errorMsg}`));
759
- process.exitCode = 1;
760
- return {
761
- name: basename(localPath),
762
- version: "local",
763
- success: false,
764
- error: errorMsg,
765
- };
766
- }
767
- }