@oh-my-pi/cli 0.1.0 → 0.2.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 (78) 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 +131 -145
  6. package/biome.json +1 -1
  7. package/dist/cli.js +2032 -1136
  8. package/dist/commands/create.d.ts.map +1 -1
  9. package/dist/commands/doctor.d.ts +1 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/enable.d.ts +1 -0
  12. package/dist/commands/enable.d.ts.map +1 -1
  13. package/dist/commands/info.d.ts +1 -0
  14. package/dist/commands/info.d.ts.map +1 -1
  15. package/dist/commands/init.d.ts.map +1 -1
  16. package/dist/commands/install.d.ts +1 -0
  17. package/dist/commands/install.d.ts.map +1 -1
  18. package/dist/commands/link.d.ts +2 -0
  19. package/dist/commands/link.d.ts.map +1 -1
  20. package/dist/commands/list.d.ts +1 -0
  21. package/dist/commands/list.d.ts.map +1 -1
  22. package/dist/commands/outdated.d.ts +1 -0
  23. package/dist/commands/outdated.d.ts.map +1 -1
  24. package/dist/commands/search.d.ts.map +1 -1
  25. package/dist/commands/uninstall.d.ts +1 -0
  26. package/dist/commands/uninstall.d.ts.map +1 -1
  27. package/dist/commands/update.d.ts +1 -0
  28. package/dist/commands/update.d.ts.map +1 -1
  29. package/dist/commands/why.d.ts +1 -0
  30. package/dist/commands/why.d.ts.map +1 -1
  31. package/dist/conflicts.d.ts +9 -1
  32. package/dist/conflicts.d.ts.map +1 -1
  33. package/dist/errors.d.ts +8 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/index.d.ts +19 -19
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/lock.d.ts +3 -0
  38. package/dist/lock.d.ts.map +1 -0
  39. package/dist/lockfile.d.ts +52 -0
  40. package/dist/lockfile.d.ts.map +1 -0
  41. package/dist/manifest.d.ts +5 -0
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/migrate.d.ts.map +1 -1
  44. package/dist/npm.d.ts +14 -2
  45. package/dist/npm.d.ts.map +1 -1
  46. package/dist/paths.d.ts +34 -2
  47. package/dist/paths.d.ts.map +1 -1
  48. package/dist/symlinks.d.ts +10 -4
  49. package/dist/symlinks.d.ts.map +1 -1
  50. package/package.json +7 -2
  51. package/plugins/metal-theme/package.json +6 -1
  52. package/plugins/subagents/package.json +6 -1
  53. package/src/cli.ts +69 -43
  54. package/src/commands/create.ts +51 -1
  55. package/src/commands/doctor.ts +95 -7
  56. package/src/commands/enable.ts +25 -8
  57. package/src/commands/info.ts +41 -5
  58. package/src/commands/init.ts +20 -2
  59. package/src/commands/install.ts +266 -52
  60. package/src/commands/link.ts +60 -9
  61. package/src/commands/list.ts +10 -5
  62. package/src/commands/outdated.ts +17 -6
  63. package/src/commands/search.ts +20 -3
  64. package/src/commands/uninstall.ts +57 -6
  65. package/src/commands/update.ts +67 -9
  66. package/src/commands/why.ts +47 -16
  67. package/src/conflicts.ts +33 -1
  68. package/src/errors.ts +22 -0
  69. package/src/index.ts +19 -25
  70. package/src/lock.ts +46 -0
  71. package/src/lockfile.ts +132 -0
  72. package/src/manifest.ts +143 -35
  73. package/src/migrate.ts +14 -3
  74. package/src/npm.ts +74 -18
  75. package/src/paths.ts +77 -9
  76. package/src/symlinks.ts +134 -17
  77. package/tsconfig.json +7 -3
  78. package/CHECK.md +0 -352
@@ -1,24 +1,58 @@
1
- import { execSync } from "node:child_process";
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 chalk from "chalk";
7
- import { type Conflict, detectConflicts, formatConflicts } from "../conflicts.js";
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,
12
13
  type PluginPackageJson,
13
14
  readPluginPackageJson,
14
15
  savePluginsJson,
15
- } from "../manifest.js";
16
- import { npmInfo, npmInstall } from "../npm.js";
17
- import { NODE_MODULES_DIR, PLUGINS_DIR, PROJECT_NODE_MODULES, PROJECT_PLUGINS_JSON } from "../paths.js";
18
- import { createPluginSymlinks } from "../symlinks.js";
16
+ } from "@omp/manifest";
17
+ import { npmInfo, npmInstall } from "@omp/npm";
18
+ import {
19
+ NODE_MODULES_DIR,
20
+ PI_CONFIG_DIR,
21
+ PLUGINS_DIR,
22
+ PROJECT_NODE_MODULES,
23
+ PROJECT_PI_DIR,
24
+ resolveScope,
25
+ } from "@omp/paths";
26
+ import { createPluginSymlinks } from "@omp/symlinks";
27
+ import chalk from "chalk";
28
+
29
+ /**
30
+ * Process omp dependencies recursively with cycle detection.
31
+ * Creates symlinks for dependencies that have omp.install entries.
32
+ */
33
+ async function processOmpDependencies(pkgJson: PluginPackageJson, isGlobal: boolean, seen: Set<string>): Promise<void> {
34
+ if (!pkgJson.dependencies) return;
35
+
36
+ for (const depName of Object.keys(pkgJson.dependencies)) {
37
+ if (seen.has(depName)) {
38
+ console.log(chalk.yellow(` Skipping circular dependency: ${depName}`));
39
+ continue;
40
+ }
41
+ seen.add(depName);
42
+
43
+ const depPkgJson = await readPluginPackageJson(depName, isGlobal);
44
+ if (depPkgJson?.omp?.install) {
45
+ console.log(chalk.dim(` Processing dependency: ${depName}`));
46
+ await createPluginSymlinks(depName, depPkgJson, isGlobal);
47
+ // Recurse into this dependency's dependencies
48
+ await processOmpDependencies(depPkgJson, isGlobal, seen);
49
+ }
50
+ }
51
+ }
19
52
 
20
53
  export interface InstallOptions {
21
54
  global?: boolean;
55
+ local?: boolean;
22
56
  save?: boolean;
23
57
  saveDev?: boolean;
24
58
  force?: boolean;
@@ -93,7 +127,7 @@ function isLocalPath(spec: string): boolean {
93
127
  * omp install [pkg...]
94
128
  */
95
129
  export async function installPlugin(packages?: string[], options: InstallOptions = {}): Promise<void> {
96
- const isGlobal = options.global !== false; // Default to global
130
+ const isGlobal = resolveScope(options);
97
131
  const prefix = isGlobal ? PLUGINS_DIR : ".pi";
98
132
  const _nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
99
133
 
@@ -101,24 +135,29 @@ export async function installPlugin(packages?: string[], options: InstallOptions
101
135
  if (isGlobal) {
102
136
  await initGlobalPlugins();
103
137
  } else {
104
- // Ensure project .pi directory exists
105
- await mkdir(prefix, { recursive: true });
106
- // Initialize plugins.json if it doesn't exist
107
- if (!existsSync(PROJECT_PLUGINS_JSON)) {
108
- await savePluginsJson({ plugins: {} }, false);
109
- }
138
+ // Initialize project .pi directory with both plugins.json and package.json
139
+ await initProjectPlugins();
110
140
  }
111
141
 
112
142
  // If no packages specified, install from plugins.json
113
143
  if (!packages || packages.length === 0) {
114
144
  const pluginsJson = await loadPluginsJson(isGlobal);
115
- packages = Object.entries(pluginsJson.plugins).map(([name, version]) => `${name}@${version}`);
145
+ // Prefer locked versions for reproducible installs
146
+ const lockFile = await import("@omp/lockfile").then((m) => m.loadLockFile(isGlobal));
147
+ packages = await Promise.all(
148
+ Object.entries(pluginsJson.plugins).map(async ([name, version]) => {
149
+ // Use locked version if available for reproducibility
150
+ const lockedVersion = lockFile?.packages[name]?.version;
151
+ return `${name}@${lockedVersion || version}`;
152
+ }),
153
+ );
116
154
 
117
155
  if (packages.length === 0) {
118
156
  console.log(chalk.yellow("No plugins to install."));
119
157
  console.log(
120
158
  chalk.dim(isGlobal ? "Add plugins with: omp install <package>" : "Add plugins to .pi/plugins.json"),
121
159
  );
160
+ process.exitCode = 1;
122
161
  return;
123
162
  }
124
163
 
@@ -143,24 +182,90 @@ export async function installPlugin(packages?: string[], options: InstallOptions
143
182
  const { name, version } = parsePackageSpec(spec);
144
183
  const pkgSpec = version === "latest" ? name : `${name}@${version}`;
145
184
 
185
+ // Track installation state for rollback
186
+ let npmInstallSucceeded = false;
187
+ let createdSymlinks: string[] = [];
188
+ let resolvedVersion = version;
189
+
146
190
  try {
147
191
  console.log(chalk.blue(`\nInstalling ${pkgSpec}...`));
148
192
 
149
- // 1. Resolve version from npm registry
193
+ // 1. Resolve version and fetch package metadata from npm registry
194
+ // npm info includes omp field if present in package.json
150
195
  const info = await npmInfo(pkgSpec);
151
196
  if (!info) {
152
197
  console.log(chalk.red(` ✗ Package not found: ${name}`));
198
+ process.exitCode = 1;
153
199
  results.push({ name, version, success: false, error: "Package not found" });
154
200
  continue;
155
201
  }
202
+ resolvedVersion = info.version;
203
+
204
+ // 2. Check for conflicts BEFORE npm install using registry metadata
205
+ const skipDestinations = new Set<string>();
206
+ const preInstallPkgJson = info.omp?.install ? { name: info.name, version: info.version, omp: info.omp } : null;
207
+
208
+ if (preInstallPkgJson) {
209
+ // Check for intra-plugin duplicates first
210
+ const intraDupes = detectIntraPluginDuplicates(preInstallPkgJson);
211
+ if (intraDupes.length > 0) {
212
+ console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
213
+ for (const dupe of intraDupes) {
214
+ console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
215
+ }
216
+ process.exitCode = 1;
217
+ results.push({ name, version: info.version, success: false, error: "Duplicate destinations in plugin" });
218
+ continue;
219
+ }
156
220
 
157
- // 2. Check for conflicts before installing
158
- // We need to fetch the package.json to check omp.install
159
- // For now, we'll check after npm install and rollback if needed
221
+ const preInstallConflicts = detectConflicts(name, preInstallPkgJson, existingPlugins);
222
+
223
+ if (preInstallConflicts.length > 0 && !options.force) {
224
+ // Check for non-interactive terminal (CI environments)
225
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
226
+ console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
227
+ for (const conflict of preInstallConflicts) {
228
+ console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
229
+ }
230
+ process.exitCode = 1;
231
+ results.push({
232
+ name,
233
+ version: info.version,
234
+ success: false,
235
+ error: "Conflicts in non-interactive mode",
236
+ });
237
+ continue;
238
+ }
239
+
240
+ // Handle conflicts BEFORE downloading the package
241
+ let abort = false;
242
+ for (const conflict of preInstallConflicts) {
243
+ const choice = await promptConflictResolution(conflict);
244
+ if (choice === null) {
245
+ abort = true;
246
+ break;
247
+ }
248
+ // choice is 0-indexed: 0 = first plugin (existing), last index = new plugin
249
+ const newPluginIndex = conflict.plugins.length - 1;
250
+ if (choice !== newPluginIndex) {
251
+ // User chose an existing plugin, skip this destination
252
+ skipDestinations.add(conflict.dest);
253
+ }
254
+ }
160
255
 
161
- // 3. npm install
256
+ if (abort) {
257
+ console.log(chalk.yellow(` Aborted due to conflicts (before download)`));
258
+ process.exitCode = 1;
259
+ results.push({ name, version: info.version, success: false, error: "Conflicts" });
260
+ continue;
261
+ }
262
+ }
263
+ }
264
+
265
+ // 3. npm install - only reached if no conflicts or user resolved them
162
266
  console.log(chalk.dim(` Fetching from npm...`));
163
267
  await npmInstall([pkgSpec], prefix, { save: options.save || isGlobal });
268
+ npmInstallSucceeded = true;
164
269
 
165
270
  // 4. Read package.json from installed package
166
271
  const pkgJson = await readPluginPackageJson(name, isGlobal);
@@ -170,55 +275,133 @@ export async function installPlugin(packages?: string[], options: InstallOptions
170
275
  continue;
171
276
  }
172
277
 
173
- // 5. Check for conflicts
174
- const conflicts = detectConflicts(name, pkgJson, existingPlugins);
175
-
176
- if (conflicts.length > 0 && !options.force) {
177
- // Handle conflicts
178
- let abort = false;
179
- for (const conflict of conflicts) {
180
- const choice = await promptConflictResolution(conflict);
181
- if (choice === null) {
182
- abort = true;
183
- break;
278
+ // 5. Re-check conflicts with full package.json if we didn't check pre-install
279
+ // This handles edge cases where omp field wasn't in registry metadata
280
+ if (!preInstallPkgJson) {
281
+ // Check for intra-plugin duplicates first
282
+ const intraDupes = detectIntraPluginDuplicates(pkgJson);
283
+ if (intraDupes.length > 0) {
284
+ console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
285
+ for (const dupe of intraDupes) {
286
+ console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
184
287
  }
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
288
  // Rollback: uninstall the package
193
- execSync(`npm uninstall --prefix ${prefix} ${name}`, { stdio: "pipe" });
194
- results.push({ name, version: info.version, success: false, error: "Conflicts" });
289
+ execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
290
+ process.exitCode = 1;
291
+ results.push({ name, version: info.version, success: false, error: "Duplicate destinations in plugin" });
195
292
  continue;
196
293
  }
197
- }
198
294
 
199
- // 6. Create symlinks for omp.install entries
200
- const _symlinkResult = await createPluginSymlinks(name, pkgJson, isGlobal);
295
+ const conflicts = detectConflicts(name, pkgJson, existingPlugins);
296
+
297
+ if (conflicts.length > 0 && !options.force) {
298
+ // Check for non-interactive terminal (CI environments)
299
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
300
+ console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
301
+ for (const conflict of conflicts) {
302
+ console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
303
+ }
304
+ // Rollback: uninstall the package
305
+ execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
306
+ process.exitCode = 1;
307
+ results.push({
308
+ name,
309
+ version: info.version,
310
+ success: false,
311
+ error: "Conflicts in non-interactive mode",
312
+ });
313
+ continue;
314
+ }
315
+
316
+ let abort = false;
317
+ for (const conflict of conflicts) {
318
+ const choice = await promptConflictResolution(conflict);
319
+ if (choice === null) {
320
+ abort = true;
321
+ break;
322
+ }
323
+ const newPluginIndex = conflict.plugins.length - 1;
324
+ if (choice !== newPluginIndex) {
325
+ skipDestinations.add(conflict.dest);
326
+ }
327
+ }
201
328
 
202
- // 7. Process dependencies with omp field
203
- if (pkgJson.dependencies) {
204
- for (const depName of Object.keys(pkgJson.dependencies)) {
205
- const depPkgJson = await readPluginPackageJson(depName, isGlobal);
206
- if (depPkgJson?.omp?.install) {
207
- console.log(chalk.dim(` Processing dependency: ${depName}`));
208
- await createPluginSymlinks(depName, depPkgJson, isGlobal);
329
+ if (abort) {
330
+ console.log(chalk.yellow(` Aborted due to conflicts`));
331
+ // Rollback: uninstall the package
332
+ execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
333
+ process.exitCode = 1;
334
+ results.push({ name, version: info.version, success: false, error: "Conflicts" });
335
+ continue;
209
336
  }
210
337
  }
211
338
  }
212
339
 
340
+ // 6. Create symlinks for omp.install entries (skip destinations user assigned to existing plugins)
341
+ const symlinkResult = await createPluginSymlinks(name, pkgJson, isGlobal, true, skipDestinations);
342
+ createdSymlinks = symlinkResult.created;
343
+
344
+ // 7. Process dependencies with omp field (with cycle detection)
345
+ await processOmpDependencies(pkgJson, isGlobal, new Set([name]));
346
+
347
+ // 8. Update manifest if --save or --save-dev was passed
348
+ // For global mode, npm --save already updates package.json dependencies
349
+ // but we need to handle devDependencies manually
350
+ // For project-local mode, we must manually update plugins.json
351
+ if (options.save || options.saveDev) {
352
+ const pluginsJson = await loadPluginsJson(isGlobal);
353
+ if (options.saveDev) {
354
+ // Save to devDependencies
355
+ if (!pluginsJson.devDependencies) {
356
+ pluginsJson.devDependencies = {};
357
+ }
358
+ pluginsJson.devDependencies[name] = info.version;
359
+ // Remove from plugins if it was there
360
+ delete pluginsJson.plugins[name];
361
+ } else if (!isGlobal) {
362
+ // Save to plugins (project-local mode only - npm handles global)
363
+ pluginsJson.plugins[name] = info.version;
364
+ }
365
+ await savePluginsJson(pluginsJson, isGlobal);
366
+ }
367
+
213
368
  // Add to installed plugins map for subsequent conflict detection
214
369
  existingPlugins.set(name, pkgJson);
215
370
 
371
+ // Update lock file with exact version
372
+ await updateLockFile(name, info.version, isGlobal);
373
+
216
374
  console.log(chalk.green(`✓ Installed ${name}@${info.version}`));
217
375
  results.push({ name, version: info.version, success: true });
218
376
  } catch (err) {
219
377
  const errorMsg = (err as Error).message;
220
378
  console.log(chalk.red(` ✗ Failed to install ${name}: ${errorMsg}`));
221
- results.push({ name, version, success: false, error: errorMsg });
379
+
380
+ // Rollback: remove any symlinks that were created
381
+ if (createdSymlinks.length > 0) {
382
+ console.log(chalk.dim(" Rolling back symlinks..."));
383
+ const baseDir = isGlobal ? PI_CONFIG_DIR : PROJECT_PI_DIR;
384
+ for (const dest of createdSymlinks) {
385
+ try {
386
+ await rm(join(baseDir, dest), { force: true, recursive: true });
387
+ } catch {
388
+ // Ignore cleanup errors
389
+ }
390
+ }
391
+ }
392
+
393
+ // Rollback: uninstall npm package if it was installed
394
+ if (npmInstallSucceeded) {
395
+ console.log(chalk.dim(" Rolling back npm install..."));
396
+ try {
397
+ execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
398
+ } catch {
399
+ // Ignore cleanup errors
400
+ }
401
+ }
402
+
403
+ process.exitCode = 1;
404
+ results.push({ name, version: resolvedVersion, success: false, error: errorMsg });
222
405
  }
223
406
  }
224
407
 
@@ -232,6 +415,7 @@ export async function installPlugin(packages?: string[], options: InstallOptions
232
415
  }
233
416
  if (failed.length > 0) {
234
417
  console.log(chalk.red(`✗ Failed to install ${failed.length} plugin(s)`));
418
+ process.exitCode = 1;
235
419
  }
236
420
 
237
421
  if (options.json) {
@@ -255,6 +439,7 @@ async function installLocalPlugin(
255
439
 
256
440
  if (!existsSync(localPath)) {
257
441
  console.log(chalk.red(`Error: Path does not exist: ${localPath}`));
442
+ process.exitCode = 1;
258
443
  return { name: basename(localPath), version: "local", success: false, error: "Path not found" };
259
444
  }
260
445
 
@@ -295,6 +480,22 @@ async function installLocalPlugin(
295
480
  const pluginName = pkgJson.name;
296
481
  const pluginDir = join(nodeModules, pluginName);
297
482
 
483
+ // Check for intra-plugin duplicates
484
+ const intraDupes = detectIntraPluginDuplicates(pkgJson);
485
+ if (intraDupes.length > 0) {
486
+ console.log(chalk.red(`\nError: Plugin has duplicate destinations:`));
487
+ for (const dupe of intraDupes) {
488
+ console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
489
+ }
490
+ process.exitCode = 1;
491
+ return {
492
+ name: pluginName,
493
+ version: pkgJson.version,
494
+ success: false,
495
+ error: "Duplicate destinations in plugin",
496
+ };
497
+ }
498
+
298
499
  console.log(chalk.blue(`\nInstalling ${pluginName} from ${localPath}...`));
299
500
 
300
501
  // Create node_modules directory
@@ -311,17 +512,30 @@ async function installLocalPlugin(
311
512
 
312
513
  // Update plugins.json/package.json
313
514
  const pluginsJson = await loadPluginsJson(isGlobal);
314
- pluginsJson.plugins[pluginName] = `file:${localPath}`;
515
+ if (_options.saveDev) {
516
+ if (!pluginsJson.devDependencies) {
517
+ pluginsJson.devDependencies = {};
518
+ }
519
+ pluginsJson.devDependencies[pluginName] = `file:${localPath}`;
520
+ // Remove from plugins if it was there
521
+ delete pluginsJson.plugins[pluginName];
522
+ } else {
523
+ pluginsJson.plugins[pluginName] = `file:${localPath}`;
524
+ }
315
525
  await savePluginsJson(pluginsJson, isGlobal);
316
526
 
317
527
  // Create symlinks
318
528
  await createPluginSymlinks(pluginName, pkgJson, isGlobal);
319
529
 
530
+ // Update lock file for local plugin
531
+ await updateLockFile(pluginName, pkgJson.version, isGlobal);
532
+
320
533
  console.log(chalk.green(`✓ Installed ${pluginName}@${pkgJson.version}`));
321
534
  return { name: pluginName, version: pkgJson.version, success: true };
322
535
  } catch (err) {
323
536
  const errorMsg = (err as Error).message;
324
537
  console.log(chalk.red(` ✗ Failed: ${errorMsg}`));
538
+ process.exitCode = 1;
325
539
  return { name: basename(localPath), version: "local", success: false, error: errorMsg };
326
540
  }
327
541
  }
@@ -1,14 +1,32 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, rm, symlink } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { loadPluginsJson, type PluginPackageJson, savePluginsJson } from "@omp/manifest";
6
+ import { NODE_MODULES_DIR, PROJECT_NODE_MODULES, resolveScope } from "@omp/paths";
7
+ import { createPluginSymlinks } from "@omp/symlinks";
4
8
  import chalk from "chalk";
5
- import { loadPluginsJson, type PluginPackageJson, savePluginsJson } from "../manifest.js";
6
- import { NODE_MODULES_DIR, PROJECT_NODE_MODULES } from "../paths.js";
7
- import { createPluginSymlinks } from "../symlinks.js";
9
+
10
+ async function confirmCreate(path: string): Promise<boolean> {
11
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
12
+ console.log(chalk.dim(" Non-interactive mode: auto-creating package.json"));
13
+ return true;
14
+ }
15
+
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ return new Promise((resolve) => {
18
+ rl.question(chalk.yellow(` Create minimal package.json at ${path}? [Y/n] `), (answer) => {
19
+ rl.close();
20
+ resolve(answer.toLowerCase() !== "n");
21
+ });
22
+ });
23
+ }
8
24
 
9
25
  export interface LinkOptions {
10
26
  name?: string;
11
27
  global?: boolean;
28
+ local?: boolean;
29
+ force?: boolean;
12
30
  }
13
31
 
14
32
  /**
@@ -16,7 +34,7 @@ export interface LinkOptions {
16
34
  * Creates a symlink in node_modules pointing to the local directory
17
35
  */
18
36
  export async function linkPlugin(localPath: string, options: LinkOptions = {}): Promise<void> {
19
- const isGlobal = options.global !== false;
37
+ const isGlobal = resolveScope(options);
20
38
  const nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
21
39
 
22
40
  // Expand ~ to home directory
@@ -28,6 +46,7 @@ export async function linkPlugin(localPath: string, options: LinkOptions = {}):
28
46
  // Verify the path exists
29
47
  if (!existsSync(localPath)) {
30
48
  console.log(chalk.red(`Error: Path does not exist: ${localPath}`));
49
+ process.exitCode = 1;
31
50
  return;
32
51
  }
33
52
 
@@ -50,13 +69,29 @@ export async function linkPlugin(localPath: string, options: LinkOptions = {}):
50
69
  install: ompJson.install,
51
70
  },
52
71
  };
72
+
73
+ // Persist the conversion to package.json
74
+ console.log(chalk.dim(" Converting omp.json to package.json..."));
75
+ await writeFile(localPkgJsonPath, JSON.stringify(pkgJson, null, 2));
53
76
  } else {
77
+ // Create minimal package.json so npm operations work correctly
78
+ console.log(chalk.yellow(" No package.json found in target directory."));
79
+ const shouldCreate = await confirmCreate(localPkgJsonPath);
80
+ if (!shouldCreate) {
81
+ console.log(chalk.yellow(" Aborted: package.json required for linking"));
82
+ process.exitCode = 1;
83
+ return;
84
+ }
54
85
  pkgJson = {
55
86
  name: options.name || basename(localPath),
56
87
  version: "0.0.0-dev",
57
88
  keywords: ["omp-plugin"],
89
+ omp: {
90
+ install: [],
91
+ },
58
92
  };
59
- console.log(chalk.yellow(" Warning: No package.json or omp.json found"));
93
+ console.log(chalk.dim(" Creating minimal package.json..."));
94
+ await writeFile(localPkgJsonPath, JSON.stringify(pkgJson, null, 2));
60
95
  }
61
96
 
62
97
  const pluginName = options.name || pkgJson.name;
@@ -65,9 +100,24 @@ export async function linkPlugin(localPath: string, options: LinkOptions = {}):
65
100
  // Check if already installed
66
101
  const pluginsJson = await loadPluginsJson(isGlobal);
67
102
  if (pluginsJson.plugins[pluginName]) {
68
- console.log(chalk.yellow(`Plugin "${pluginName}" is already installed.`));
69
- console.log(chalk.dim("Use omp uninstall first, or specify a different name with -n"));
70
- return;
103
+ const existingSpec = pluginsJson.plugins[pluginName];
104
+ const isLinked = existingSpec.startsWith("file:");
105
+
106
+ if (isLinked) {
107
+ console.log(chalk.yellow(`Plugin "${pluginName}" is already linked.`));
108
+ console.log(chalk.dim(` Current link: ${existingSpec}`));
109
+ console.log(chalk.dim(" Re-linking..."));
110
+ // Continue with the linking process (will overwrite)
111
+ } else if (options.force) {
112
+ console.log(chalk.yellow(`Plugin "${pluginName}" is installed from npm. Overwriting with link...`));
113
+ // Continue with the linking process (will overwrite)
114
+ } else {
115
+ console.log(chalk.yellow(`Plugin "${pluginName}" is already installed from npm.`));
116
+ console.log(chalk.dim("Use omp uninstall first, or specify a different name with -n"));
117
+ console.log(chalk.dim("Or use --force to overwrite the npm installation"));
118
+ process.exitCode = 1;
119
+ return;
120
+ }
71
121
  }
72
122
 
73
123
  try {
@@ -100,6 +150,7 @@ export async function linkPlugin(localPath: string, options: LinkOptions = {}):
100
150
  console.log(chalk.dim(" Changes to the source will be reflected immediately"));
101
151
  } catch (err) {
102
152
  console.log(chalk.red(`Error linking plugin: ${(err as Error).message}`));
153
+ process.exitCode = 1;
103
154
  // Cleanup on failure
104
155
  try {
105
156
  await rm(pluginDir, { force: true });
@@ -1,8 +1,10 @@
1
+ import { loadPluginsJson, readPluginPackageJson } from "@omp/manifest";
2
+ import { resolveScope } from "@omp/paths";
1
3
  import chalk from "chalk";
2
- import { loadPluginsJson, readPluginPackageJson } from "../manifest.js";
3
4
 
4
5
  export interface ListOptions {
5
6
  global?: boolean;
7
+ local?: boolean;
6
8
  json?: boolean;
7
9
  }
8
10
 
@@ -10,13 +12,14 @@ export interface ListOptions {
10
12
  * List all installed plugins
11
13
  */
12
14
  export async function listPlugins(options: ListOptions = {}): Promise<void> {
13
- const isGlobal = options.global !== false;
15
+ const isGlobal = resolveScope(options);
14
16
  const pluginsJson = await loadPluginsJson(isGlobal);
15
17
  const pluginNames = Object.keys(pluginsJson.plugins);
16
18
 
17
19
  if (pluginNames.length === 0) {
18
20
  console.log(chalk.yellow("No plugins installed."));
19
21
  console.log(chalk.dim("Install one with: omp install <package>"));
22
+ process.exitCode = 1;
20
23
  return;
21
24
  }
22
25
 
@@ -44,13 +47,15 @@ export async function listPlugins(options: ListOptions = {}): Promise<void> {
44
47
  const specifier = pluginsJson.plugins[name];
45
48
  const isLocal = specifier.startsWith("file:");
46
49
  const disabled = pluginsJson.disabled?.includes(name);
50
+ const isMissing = !pkgJson;
47
51
 
48
- const version = pkgJson?.version ? chalk.dim(` v${pkgJson.version}`) : "";
52
+ const version = pkgJson?.version ? chalk.dim(` v${pkgJson.version}`) : chalk.dim(` (${specifier})`);
49
53
  const localBadge = isLocal ? chalk.cyan(" (local)") : "";
50
54
  const disabledBadge = disabled ? chalk.yellow(" (disabled)") : "";
51
- const icon = disabled ? chalk.gray("") : chalk.green("");
55
+ const missingBadge = isMissing ? chalk.red(" (missing)") : "";
56
+ const icon = disabled ? chalk.gray("○") : isMissing ? chalk.red("✗") : chalk.green("◆");
52
57
 
53
- console.log(`${icon} ${chalk.bold(name)}${version}${localBadge}${disabledBadge}`);
58
+ console.log(`${icon} ${chalk.bold(name)}${version}${localBadge}${disabledBadge}${missingBadge}`);
54
59
 
55
60
  if (pkgJson?.description) {
56
61
  console.log(chalk.dim(` ${pkgJson.description}`));
@@ -1,10 +1,11 @@
1
+ import { loadPluginsJson } from "@omp/manifest";
2
+ import { npmOutdated } from "@omp/npm";
3
+ import { PLUGINS_DIR, resolveScope } from "@omp/paths";
1
4
  import chalk from "chalk";
2
- import { loadPluginsJson } from "../manifest.js";
3
- import { npmOutdated } from "../npm.js";
4
- import { PLUGINS_DIR } from "../paths.js";
5
5
 
6
6
  export interface OutdatedOptions {
7
7
  global?: boolean;
8
+ local?: boolean;
8
9
  json?: boolean;
9
10
  }
10
11
 
@@ -12,7 +13,7 @@ export interface OutdatedOptions {
12
13
  * List plugins with newer versions available
13
14
  */
14
15
  export async function showOutdated(options: OutdatedOptions = {}): Promise<void> {
15
- const isGlobal = options.global !== false;
16
+ const isGlobal = resolveScope(options);
16
17
  const prefix = isGlobal ? PLUGINS_DIR : ".pi";
17
18
 
18
19
  console.log(chalk.blue("Checking for outdated plugins..."));
@@ -21,9 +22,12 @@ export async function showOutdated(options: OutdatedOptions = {}): Promise<void>
21
22
  const outdated = await npmOutdated(prefix);
22
23
  const pluginsJson = await loadPluginsJson(isGlobal);
23
24
 
24
- // Filter to only show plugins we manage
25
+ // Filter to only show plugins we manage AND are not local
25
26
  const managedOutdated = Object.entries(outdated).filter(([name]) => {
26
- return pluginsJson.plugins[name] !== undefined;
27
+ const specifier = pluginsJson.plugins[name];
28
+ if (!specifier) return false; // Not in our manifest
29
+ if (specifier.startsWith("file:")) return false; // Local plugin, skip
30
+ return true;
27
31
  });
28
32
 
29
33
  if (managedOutdated.length === 0) {
@@ -66,11 +70,18 @@ export async function showOutdated(options: OutdatedOptions = {}): Promise<void>
66
70
  );
67
71
  }
68
72
 
73
+ // Note about local plugins excluded from check
74
+ const localPlugins = Object.entries(pluginsJson.plugins).filter(([_, spec]) => spec.startsWith("file:"));
75
+ if (localPlugins.length > 0) {
76
+ console.log(chalk.dim(`\nNote: ${localPlugins.length} local plugin(s) excluded from check`));
77
+ }
78
+
69
79
  console.log();
70
80
  console.log(chalk.dim("Update with: omp update [package]"));
71
81
  console.log(chalk.dim(" - 'wanted' = latest within semver range"));
72
82
  console.log(chalk.dim(" - 'latest' = latest available version"));
73
83
  } catch (err) {
74
84
  console.log(chalk.red(`Error checking outdated: ${(err as Error).message}`));
85
+ process.exitCode = 1;
75
86
  }
76
87
  }