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