@fresh-editor/fresh-editor 0.1.97 → 0.1.99

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/plugins/pkg.ts CHANGED
@@ -40,6 +40,7 @@ const CONFIG_DIR = editor.getConfigDir();
40
40
  const PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "plugins", "packages");
41
41
  const THEMES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "themes", "packages");
42
42
  const LANGUAGES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "languages", "packages");
43
+ const BUNDLES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "bundles", "packages");
43
44
  const INDEX_DIR = editor.pathJoin(PACKAGES_DIR, ".index");
44
45
  const CACHE_DIR = editor.pathJoin(PACKAGES_DIR, ".cache");
45
46
  const LOCKFILE_PATH = editor.pathJoin(CONFIG_DIR, "fresh.lock");
@@ -58,11 +59,60 @@ const DEFAULT_REGISTRY = "https://github.com/sinelaw/fresh-plugins-registry";
58
59
  // - docs/internal/package-index-template/schemas/package.schema.json
59
60
  // - crates/fresh-editor/plugins/schemas/package.schema.json
60
61
 
62
+ // Bundle language definition (used in fresh.languages[])
63
+ interface BundleLanguage {
64
+ /** Language identifier (e.g., 'elixir', 'heex') */
65
+ id: string;
66
+ /** Grammar configuration */
67
+ grammar?: {
68
+ file: string;
69
+ extensions?: string[];
70
+ firstLine?: string;
71
+ };
72
+ /** Language configuration */
73
+ language?: {
74
+ commentPrefix?: string;
75
+ blockCommentStart?: string;
76
+ blockCommentEnd?: string;
77
+ useTabs?: boolean;
78
+ tabSize?: number;
79
+ autoIndent?: boolean;
80
+ showWhitespaceTabs?: boolean;
81
+ formatter?: {
82
+ command: string;
83
+ args?: string[];
84
+ };
85
+ };
86
+ /** LSP server configuration */
87
+ lsp?: {
88
+ command: string;
89
+ args?: string[];
90
+ autoStart?: boolean;
91
+ initializationOptions?: Record<string, unknown>;
92
+ };
93
+ }
94
+
95
+ // Bundle plugin definition (used in fresh.plugins[])
96
+ interface BundlePlugin {
97
+ /** Plugin entry point file relative to package */
98
+ entry: string;
99
+ }
100
+
101
+ // Bundle theme definition (used in fresh.themes[])
102
+ interface BundleTheme {
103
+ /** Theme JSON file path relative to package */
104
+ file: string;
105
+ /** Display name for the theme */
106
+ name: string;
107
+ /** Theme variant (dark or light) */
108
+ variant?: "dark" | "light";
109
+ }
110
+
61
111
  interface PackageManifest {
62
112
  name: string;
63
113
  version: string;
64
114
  description: string;
65
- type: "plugin" | "theme" | "theme-pack" | "language";
115
+ type: "plugin" | "theme" | "theme-pack" | "language" | "bundle";
66
116
  author?: string;
67
117
  license?: string;
68
118
  repository?: string;
@@ -104,6 +154,12 @@ interface PackageManifest {
104
154
  autoStart?: boolean;
105
155
  initializationOptions?: Record<string, unknown>;
106
156
  };
157
+
158
+ // Bundle fields
159
+ /** Languages included in this bundle */
160
+ languages?: BundleLanguage[];
161
+ /** Plugins included in this bundle */
162
+ plugins?: BundlePlugin[];
107
163
  };
108
164
  keywords?: string[];
109
165
  }
@@ -130,7 +186,7 @@ interface RegistryData {
130
186
  interface InstalledPackage {
131
187
  name: string;
132
188
  path: string;
133
- type: "plugin" | "theme" | "language";
189
+ type: "plugin" | "theme" | "language" | "bundle";
134
190
  source: string;
135
191
  version: string;
136
192
  commit?: string;
@@ -155,12 +211,14 @@ interface Lockfile {
155
211
  // =============================================================================
156
212
 
157
213
  interface ParsedPackageUrl {
158
- /** The base git repository URL (without fragment) */
214
+ /** The base git repository URL or local path (without fragment) */
159
215
  repoUrl: string;
160
- /** Optional path within the repository (from fragment) */
216
+ /** Optional path within the repository/directory (from fragment) */
161
217
  subpath: string | null;
162
218
  /** Extracted package name */
163
219
  name: string;
220
+ /** Whether this is a local file path (not a remote URL) */
221
+ isLocal: boolean;
164
222
  }
165
223
 
166
224
  // =============================================================================
@@ -206,6 +264,29 @@ async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout:
206
264
  return result;
207
265
  }
208
266
 
267
+ /**
268
+ * Check if a string is a local file path (not a URL).
269
+ */
270
+ function isLocalPath(str: string): boolean {
271
+ // Absolute paths start with /
272
+ if (str.startsWith("/")) return true;
273
+ // Windows absolute paths (C:\, D:\, etc.)
274
+ if (/^[A-Za-z]:[\\\/]/.test(str)) return true;
275
+ // Relative paths starting with . or ..
276
+ if (str.startsWith("./") || str.startsWith("../")) return true;
277
+ // Home directory expansion
278
+ if (str.startsWith("~/")) return true;
279
+ // Not a URL scheme (http://, https://, git://, ssh://, file://)
280
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(str)) {
281
+ // If it doesn't look like a URL and doesn't contain @, it's probably a path
282
+ // (git@github.com:user/repo is a git URL)
283
+ if (!str.includes("@") || str.startsWith("/")) {
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+
209
290
  /**
210
291
  * Parse a package URL that may contain a subpath fragment.
211
292
  *
@@ -213,6 +294,8 @@ async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout:
213
294
  * - `https://github.com/user/repo` - standard repo
214
295
  * - `https://github.com/user/repo#path/to/plugin` - monorepo with subpath
215
296
  * - `https://github.com/user/repo.git#packages/my-plugin` - with .git suffix
297
+ * - `/path/to/local/repo#subdir` - local path with subpath
298
+ * - `/path/to/local/package` - direct local package path
216
299
  *
217
300
  * The fragment (after #) specifies a subdirectory within the repo.
218
301
  */
@@ -234,19 +317,22 @@ function parsePackageUrl(url: string): ParsedPackageUrl {
234
317
  repoUrl = url;
235
318
  }
236
319
 
320
+ // Determine if this is a local path
321
+ const isLocal = isLocalPath(repoUrl);
322
+
237
323
  // Extract package name
238
324
  let name: string;
239
325
  if (subpath) {
240
- // For monorepo, use the last component of the subpath
326
+ // For monorepo/directory, use the last component of the subpath
241
327
  const parts = subpath.split("/");
242
328
  name = parts[parts.length - 1].replace(/^fresh-/, "");
243
329
  } else {
244
- // For regular repo, use repo name
330
+ // For regular repo/path, use the last component
245
331
  const match = repoUrl.match(/\/([^\/]+?)(\.git)?$/);
246
332
  name = match ? match[1].replace(/^fresh-/, "") : "unknown";
247
333
  }
248
334
 
249
- return { repoUrl, subpath, name };
335
+ return { repoUrl, subpath, name, isLocal };
250
336
  }
251
337
 
252
338
  /**
@@ -446,9 +532,10 @@ function isRegistrySynced(): boolean {
446
532
  /**
447
533
  * Get list of installed packages
448
534
  */
449
- function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledPackage[] {
535
+ function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"): InstalledPackage[] {
450
536
  const packagesDir = type === "plugin" ? PACKAGES_DIR
451
537
  : type === "theme" ? THEMES_PACKAGES_DIR
538
+ : type === "bundle" ? BUNDLES_PACKAGES_DIR
452
539
  : LANGUAGES_PACKAGES_DIR;
453
540
  const packages: InstalledPackage[] = [];
454
541
 
@@ -543,14 +630,14 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
543
630
  if (!manifest.type) {
544
631
  return {
545
632
  valid: false,
546
- error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')"
633
+ error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', 'language', or 'bundle')"
547
634
  };
548
635
  }
549
636
 
550
- if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language") {
637
+ if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language" && manifest.type !== "bundle") {
551
638
  return {
552
639
  valid: false,
553
- error: `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'`
640
+ error: `Invalid package.json - 'type' must be 'plugin', 'theme', 'language', or 'bundle', got '${manifest.type}'`
554
641
  };
555
642
  }
556
643
 
@@ -598,64 +685,122 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
598
685
  return { valid: true, manifest };
599
686
  }
600
687
 
688
+ // For bundles, validate at least one language, plugin, or theme is defined
689
+ if (manifest.type === "bundle") {
690
+ const hasLanguages = manifest.fresh?.languages && manifest.fresh.languages.length > 0;
691
+ const hasPlugins = manifest.fresh?.plugins && manifest.fresh.plugins.length > 0;
692
+ const hasThemes = manifest.fresh?.themes && manifest.fresh.themes.length > 0;
693
+
694
+ if (!hasLanguages && !hasPlugins && !hasThemes) {
695
+ return {
696
+ valid: false,
697
+ error: "Bundle package must define at least one language, plugin, or theme"
698
+ };
699
+ }
700
+
701
+ // Validate each language entry
702
+ if (manifest.fresh?.languages) {
703
+ for (const lang of manifest.fresh.languages) {
704
+ if (!lang.id) {
705
+ return {
706
+ valid: false,
707
+ error: "Bundle language entry missing required 'id' field"
708
+ };
709
+ }
710
+ // Validate grammar file exists if specified
711
+ if (lang.grammar?.file) {
712
+ const grammarPath = editor.pathJoin(packageDir, lang.grammar.file);
713
+ if (!editor.fileExists(grammarPath)) {
714
+ return {
715
+ valid: false,
716
+ error: `Grammar file not found for language '${lang.id}': ${lang.grammar.file}`
717
+ };
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ // Validate each plugin entry
724
+ if (manifest.fresh?.plugins) {
725
+ for (const plugin of manifest.fresh.plugins) {
726
+ if (!plugin.entry) {
727
+ return {
728
+ valid: false,
729
+ error: "Bundle plugin entry missing required 'entry' field"
730
+ };
731
+ }
732
+ const entryPath = editor.pathJoin(packageDir, plugin.entry);
733
+ if (!editor.fileExists(entryPath)) {
734
+ // Try .js as fallback
735
+ const jsEntryPath = entryPath.replace(/\.ts$/, ".js");
736
+ if (!editor.fileExists(jsEntryPath)) {
737
+ return {
738
+ valid: false,
739
+ error: `Plugin entry file not found: ${plugin.entry}`
740
+ };
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ return { valid: true, manifest };
747
+ }
748
+
601
749
  // Themes don't need entry file validation
602
750
  return { valid: true, manifest };
603
751
  }
604
752
 
605
753
  /**
606
- * Install a package from git URL.
754
+ * Install a package from git URL or local path.
607
755
  *
608
- * Supports monorepo URLs with subpath fragments:
609
- * - `https://github.com/user/repo#packages/my-plugin`
756
+ * Supports:
757
+ * - `https://github.com/user/repo` - standard git repo
758
+ * - `https://github.com/user/repo#packages/my-plugin` - monorepo with subpath
759
+ * - `/path/to/local/repo#subdir` - local path with subpath
760
+ * - `/path/to/local/package` - direct local package path
610
761
  *
611
- * For subpath packages, clones to temp directory and copies the subdirectory.
762
+ * For subpath packages, clones/copies to temp directory and copies the subdirectory.
612
763
  */
613
764
  async function installPackage(
614
765
  url: string,
615
766
  name?: string,
616
- type: "plugin" | "theme" | "language" = "plugin",
767
+ _type?: "plugin" | "theme" | "language" | "bundle", // Ignored - type is auto-detected from manifest
617
768
  version?: string
618
769
  ): Promise<boolean> {
619
770
  const parsed = parsePackageUrl(url);
620
771
  const packageName = name || parsed.name;
621
- const packagesDir = type === "plugin" ? PACKAGES_DIR
622
- : type === "theme" ? THEMES_PACKAGES_DIR
623
- : LANGUAGES_PACKAGES_DIR;
624
- const targetDir = editor.pathJoin(packagesDir, packageName);
625
-
626
- if (editor.fileExists(targetDir)) {
627
- editor.setStatus(`Package '${packageName}' is already installed`);
628
- return false;
629
- }
630
-
631
- await ensureDir(packagesDir);
632
772
 
633
773
  editor.setStatus(`Installing ${packageName}...`);
634
774
 
635
- if (parsed.subpath) {
636
- // Monorepo installation: clone to temp, copy subdirectory
637
- return await installFromMonorepo(parsed, packageName, targetDir, version);
775
+ if (parsed.isLocal) {
776
+ // Local path installation: copy directly
777
+ return await installFromLocalPath(parsed, packageName);
778
+ } else if (parsed.subpath) {
779
+ // Remote monorepo installation: clone to temp, copy subdirectory
780
+ return await installFromMonorepo(parsed, packageName, version);
638
781
  } else {
639
- // Standard installation: clone directly
640
- return await installFromRepo(parsed.repoUrl, packageName, targetDir, version);
782
+ // Standard git installation: clone directly
783
+ return await installFromRepo(parsed.repoUrl, packageName, version);
641
784
  }
642
785
  }
643
786
 
644
787
  /**
645
788
  * Install from a standard git repository (no subpath)
789
+ * Clones to temp first to detect type, then moves to correct location.
646
790
  */
647
791
  async function installFromRepo(
648
792
  repoUrl: string,
649
793
  packageName: string,
650
- targetDir: string,
651
794
  version?: string
652
795
  ): Promise<boolean> {
653
- // Clone the repository
796
+ // Clone to temp directory first to detect package type
797
+ const tempDir = `/tmp/fresh-pkg-clone-${hashString(repoUrl)}-${Date.now()}`;
798
+
654
799
  const cloneArgs = ["clone"];
655
800
  if (!version || version === "latest") {
656
801
  cloneArgs.push("--depth", "1");
657
802
  }
658
- cloneArgs.push(`${repoUrl}`, `${targetDir}`);
803
+ cloneArgs.push(`${repoUrl}`, `${tempDir}`);
659
804
 
660
805
  const result = await gitCommand(cloneArgs);
661
806
 
@@ -671,53 +816,196 @@ async function installFromRepo(
671
816
 
672
817
  // Checkout specific version if requested
673
818
  if (version && version !== "latest") {
674
- const checkoutResult = await checkoutVersion(targetDir, version);
819
+ const checkoutResult = await checkoutVersion(tempDir, version);
675
820
  if (!checkoutResult) {
676
821
  editor.setStatus(`Installed ${packageName} but failed to checkout version ${version}`);
677
822
  }
678
823
  }
679
824
 
680
825
  // Validate package structure
681
- const validation = validatePackage(targetDir, packageName);
826
+ const validation = validatePackage(tempDir, packageName);
682
827
  if (!validation.valid) {
683
828
  editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
684
829
  editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
685
- // Clean up the invalid package
686
- await editor.spawnProcess("rm", ["-rf", targetDir]);
830
+ // Clean up
831
+ await editor.spawnProcess("rm", ["-rf", tempDir]);
687
832
  return false;
688
833
  }
689
834
 
690
835
  const manifest = validation.manifest;
691
836
 
692
- // Dynamically load plugins, reload themes, or load language packs
837
+ // Use manifest name as the authoritative package name
838
+ if (manifest?.name) packageName = manifest.name;
839
+
840
+ // Determine correct target directory based on actual package type
841
+ const actualType = manifest?.type || "plugin";
842
+ const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
843
+ : actualType === "theme" ? THEMES_PACKAGES_DIR
844
+ : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
845
+ : LANGUAGES_PACKAGES_DIR;
846
+ const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);
847
+
848
+ // Check if already installed in correct location
849
+ if (editor.fileExists(correctTargetDir)) {
850
+ editor.setStatus(`Package '${packageName}' is already installed`);
851
+ await editor.spawnProcess("rm", ["-rf", tempDir]);
852
+ return false;
853
+ }
854
+
855
+ // Ensure correct directory exists and move from temp
856
+ await ensureDir(correctPackagesDir);
857
+ const moveResult = await editor.spawnProcess("mv", [tempDir, correctTargetDir]);
858
+ if (moveResult.exit_code !== 0) {
859
+ editor.setStatus(`Failed to install ${packageName}: ${moveResult.stderr}`);
860
+ await editor.spawnProcess("rm", ["-rf", tempDir]);
861
+ return false;
862
+ }
863
+
864
+ // Dynamically load plugins, reload themes, load language packs, or load bundles
693
865
  if (manifest?.type === "plugin" && validation.entryPath) {
694
- await editor.loadPlugin(validation.entryPath);
866
+ // Update entry path to new location
867
+ const newEntryPath = validation.entryPath.replace(tempDir, correctTargetDir);
868
+ await editor.loadPlugin(newEntryPath);
695
869
  editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
696
870
  } else if (manifest?.type === "theme") {
697
871
  editor.reloadThemes();
698
872
  editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
699
873
  } else if (manifest?.type === "language") {
700
- await loadLanguagePack(targetDir, manifest);
874
+ await loadLanguagePack(correctTargetDir, manifest);
701
875
  editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
876
+ } else if (manifest?.type === "bundle") {
877
+ await loadBundle(correctTargetDir, manifest);
878
+ editor.setStatus(`Installed bundle ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
702
879
  } else {
703
880
  editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
704
881
  }
705
882
  return true;
706
883
  }
707
884
 
885
+ /**
886
+ * Install from a local file path.
887
+ *
888
+ * Strategy:
889
+ * - If subpath is specified: copy that subdirectory
890
+ * - Otherwise: copy the entire directory
891
+ * - Store the source path for reference
892
+ * - Auto-detect package type from manifest and install to correct directory
893
+ */
894
+ async function installFromLocalPath(
895
+ parsed: ParsedPackageUrl,
896
+ packageName: string
897
+ ): Promise<boolean> {
898
+ // Resolve the full source path
899
+ let sourcePath = parsed.repoUrl;
900
+
901
+ // Handle home directory expansion
902
+ if (sourcePath.startsWith("~/")) {
903
+ const home = editor.getEnv("HOME") || editor.getEnv("USERPROFILE") || "";
904
+ sourcePath = editor.pathJoin(home, sourcePath.slice(2));
905
+ }
906
+
907
+ // If there's a subpath, append it
908
+ if (parsed.subpath) {
909
+ sourcePath = editor.pathJoin(sourcePath, parsed.subpath);
910
+ }
911
+
912
+ // Check if source exists
913
+ if (!editor.fileExists(sourcePath)) {
914
+ editor.setStatus(`Local path not found: ${sourcePath}`);
915
+ return false;
916
+ }
917
+
918
+ // Check if it's a directory (by checking for package.json)
919
+ const manifestPath = editor.pathJoin(sourcePath, "package.json");
920
+ if (!editor.fileExists(manifestPath)) {
921
+ editor.setStatus(`Not a valid package (no package.json): ${sourcePath}`);
922
+ return false;
923
+ }
924
+
925
+ // Read manifest FIRST to determine actual package type and name
926
+ const manifest = readJsonFile<PackageManifest>(manifestPath);
927
+ if (!manifest) {
928
+ editor.setStatus(`Invalid package.json in ${sourcePath}`);
929
+ return false;
930
+ }
931
+
932
+ // Use manifest name as the authoritative package name
933
+ packageName = manifest.name;
934
+
935
+ // Determine correct target directory based on actual package type
936
+ const actualType = manifest.type || "plugin";
937
+ const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
938
+ : actualType === "theme" ? THEMES_PACKAGES_DIR
939
+ : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
940
+ : LANGUAGES_PACKAGES_DIR;
941
+ const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);
942
+
943
+ // Check if already installed in correct location
944
+ if (editor.fileExists(correctTargetDir)) {
945
+ editor.setStatus(`Package '${packageName}' is already installed`);
946
+ return false;
947
+ }
948
+
949
+ // Ensure correct directory exists
950
+ await ensureDir(correctPackagesDir);
951
+
952
+ // Copy the directory to correct target
953
+ editor.setStatus(`Copying from ${sourcePath}...`);
954
+ const copyResult = await editor.spawnProcess("cp", ["-r", sourcePath, correctTargetDir]);
955
+ if (copyResult.exit_code !== 0) {
956
+ editor.setStatus(`Failed to copy package: ${copyResult.stderr}`);
957
+ return false;
958
+ }
959
+
960
+ // Validate package structure
961
+ const validation = validatePackage(correctTargetDir, packageName);
962
+ if (!validation.valid) {
963
+ editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
964
+ editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
965
+ // Clean up the invalid package
966
+ await editor.spawnProcess("rm", ["-rf", correctTargetDir]);
967
+ return false;
968
+ }
969
+
970
+ // Store the source path for reference
971
+ const sourceInfo = {
972
+ local_path: sourcePath,
973
+ original_url: parsed.subpath ? `${parsed.repoUrl}#${parsed.subpath}` : parsed.repoUrl,
974
+ installed_at: new Date().toISOString()
975
+ };
976
+ await writeJsonFile(editor.pathJoin(correctTargetDir, ".fresh-source.json"), sourceInfo);
977
+
978
+ // Dynamically load plugins, reload themes, load language packs, or load bundles
979
+ if (manifest.type === "plugin" && validation.entryPath) {
980
+ await editor.loadPlugin(validation.entryPath);
981
+ editor.setStatus(`Installed and activated ${packageName} v${manifest.version || "unknown"}`);
982
+ } else if (manifest.type === "theme") {
983
+ editor.reloadThemes();
984
+ editor.setStatus(`Installed theme ${packageName} v${manifest.version || "unknown"}`);
985
+ } else if (manifest.type === "language") {
986
+ await loadLanguagePack(correctTargetDir, manifest);
987
+ editor.setStatus(`Installed language pack ${packageName} v${manifest.version || "unknown"}`);
988
+ } else if (manifest.type === "bundle") {
989
+ await loadBundle(correctTargetDir, manifest);
990
+ editor.setStatus(`Installed bundle ${packageName} v${manifest.version || "unknown"}`);
991
+ } else {
992
+ editor.setStatus(`Installed ${packageName} v${manifest.version || "unknown"}`);
993
+ }
994
+ return true;
995
+ }
996
+
708
997
  /**
709
998
  * Install from a monorepo (URL with subpath fragment)
710
999
  *
711
1000
  * Strategy:
712
1001
  * 1. Clone the repo to a temp directory
713
- * 2. Copy the subdirectory to the target location
714
- * 3. Initialize a new git repo in the target (for updates)
1002
+ * 2. Detect package type from manifest
1003
+ * 3. Copy the subdirectory to the correct target location
715
1004
  * 4. Store the original URL for reference
716
1005
  */
717
1006
  async function installFromMonorepo(
718
1007
  parsed: ParsedPackageUrl,
719
1008
  packageName: string,
720
- targetDir: string,
721
1009
  version?: string
722
1010
  ): Promise<boolean> {
723
1011
  const tempDir = `/tmp/fresh-pkg-${hashString(parsed.repoUrl)}-${Date.now()}`;
@@ -755,26 +1043,47 @@ async function installFromMonorepo(
755
1043
  return false;
756
1044
  }
757
1045
 
758
- // Copy subdirectory to target
759
- editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`);
760
- const copyResult = await editor.spawnProcess("cp", ["-r", subpathDir, targetDir]);
761
- if (copyResult.exit_code !== 0) {
762
- editor.setStatus(`Failed to copy package: ${copyResult.stderr}`);
1046
+ // Validate package structure (validates against subpath dir)
1047
+ const validation = validatePackage(subpathDir, packageName);
1048
+ if (!validation.valid) {
1049
+ editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
1050
+ editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
763
1051
  await editor.spawnProcess("rm", ["-rf", tempDir]);
764
1052
  return false;
765
1053
  }
766
1054
 
767
- // Validate package structure
768
- const validation = validatePackage(targetDir, packageName);
769
- if (!validation.valid) {
770
- editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
771
- editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
772
- // Clean up the invalid package
773
- await editor.spawnProcess("rm", ["-rf", targetDir]);
1055
+ const manifest = validation.manifest;
1056
+
1057
+ // Use manifest name as the authoritative package name
1058
+ if (manifest?.name) packageName = manifest.name;
1059
+
1060
+ // Determine correct target directory based on actual package type
1061
+ const actualType = manifest?.type || "plugin";
1062
+ const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
1063
+ : actualType === "theme" ? THEMES_PACKAGES_DIR
1064
+ : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
1065
+ : LANGUAGES_PACKAGES_DIR;
1066
+ const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);
1067
+
1068
+ // Check if already installed
1069
+ if (editor.fileExists(correctTargetDir)) {
1070
+ editor.setStatus(`Package '${packageName}' is already installed`);
1071
+ await editor.spawnProcess("rm", ["-rf", tempDir]);
1072
+ return false;
1073
+ }
1074
+
1075
+ // Ensure correct directory exists
1076
+ await ensureDir(correctPackagesDir);
1077
+
1078
+ // Copy subdirectory to correct target
1079
+ editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`);
1080
+ const copyResult = await editor.spawnProcess("cp", ["-r", subpathDir, correctTargetDir]);
1081
+ if (copyResult.exit_code !== 0) {
1082
+ editor.setStatus(`Failed to copy package: ${copyResult.stderr}`);
1083
+ await editor.spawnProcess("rm", ["-rf", tempDir]);
774
1084
  return false;
775
1085
  }
776
1086
 
777
- // Initialize git in target for future updates
778
1087
  // Store the original monorepo URL in a .fresh-source file
779
1088
  const sourceInfo = {
780
1089
  repository: parsed.repoUrl,
@@ -782,20 +1091,23 @@ async function installFromMonorepo(
782
1091
  installed_from: `${parsed.repoUrl}#${parsed.subpath}`,
783
1092
  installed_at: new Date().toISOString()
784
1093
  };
785
- await writeJsonFile(editor.pathJoin(targetDir, ".fresh-source.json"), sourceInfo);
786
-
787
- const manifest = validation.manifest;
1094
+ await writeJsonFile(editor.pathJoin(correctTargetDir, ".fresh-source.json"), sourceInfo);
788
1095
 
789
- // Dynamically load plugins, reload themes, or load language packs
1096
+ // Dynamically load plugins, reload themes, load language packs, or load bundles
790
1097
  if (manifest?.type === "plugin" && validation.entryPath) {
791
- await editor.loadPlugin(validation.entryPath);
1098
+ // Update entry path to new location
1099
+ const newEntryPath = validation.entryPath.replace(subpathDir, correctTargetDir);
1100
+ await editor.loadPlugin(newEntryPath);
792
1101
  editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
793
1102
  } else if (manifest?.type === "theme") {
794
1103
  editor.reloadThemes();
795
1104
  editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
796
1105
  } else if (manifest?.type === "language") {
797
- await loadLanguagePack(targetDir, manifest);
1106
+ await loadLanguagePack(correctTargetDir, manifest);
798
1107
  editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
1108
+ } else if (manifest?.type === "bundle") {
1109
+ await loadBundle(correctTargetDir, manifest);
1110
+ editor.setStatus(`Installed bundle ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
799
1111
  } else {
800
1112
  editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
801
1113
  }
@@ -852,6 +1164,90 @@ async function loadLanguagePack(packageDir: string, manifest: PackageManifest):
852
1164
  editor.reloadGrammars();
853
1165
  }
854
1166
 
1167
+ /**
1168
+ * Load a bundle package (register all languages and load all plugins)
1169
+ */
1170
+ async function loadBundle(packageDir: string, manifest: PackageManifest): Promise<void> {
1171
+ const bundleName = manifest.name;
1172
+ editor.debug(`[pkg] Loading bundle: ${bundleName}`);
1173
+
1174
+ // Load all languages from the bundle
1175
+ if (manifest.fresh?.languages) {
1176
+ for (const lang of manifest.fresh.languages) {
1177
+ const langId = lang.id;
1178
+ editor.debug(`[pkg] Loading bundle language: ${langId}`);
1179
+
1180
+ // Register grammar if present
1181
+ if (lang.grammar) {
1182
+ const grammarPath = editor.pathJoin(packageDir, lang.grammar.file);
1183
+ const extensions = lang.grammar.extensions || [];
1184
+ editor.registerGrammar(langId, grammarPath, extensions);
1185
+ }
1186
+
1187
+ // Register language config if present
1188
+ if (lang.language) {
1189
+ const langConfig = lang.language;
1190
+ editor.registerLanguageConfig(langId, {
1191
+ commentPrefix: langConfig.commentPrefix ?? null,
1192
+ blockCommentStart: langConfig.blockCommentStart ?? null,
1193
+ blockCommentEnd: langConfig.blockCommentEnd ?? null,
1194
+ useTabs: langConfig.useTabs ?? null,
1195
+ tabSize: langConfig.tabSize ?? null,
1196
+ autoIndent: langConfig.autoIndent ?? null,
1197
+ showWhitespaceTabs: langConfig.showWhitespaceTabs ?? null,
1198
+ formatter: langConfig.formatter ? {
1199
+ command: langConfig.formatter.command,
1200
+ args: langConfig.formatter.args ?? [],
1201
+ } : null,
1202
+ });
1203
+ }
1204
+
1205
+ // Register LSP server if present
1206
+ if (lang.lsp) {
1207
+ const lsp = lang.lsp;
1208
+ editor.registerLspServer(langId, {
1209
+ command: lsp.command,
1210
+ args: lsp.args ?? [],
1211
+ autoStart: lsp.autoStart ?? null,
1212
+ initializationOptions: lsp.initializationOptions ?? null,
1213
+ });
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ // Load all plugins from the bundle
1219
+ if (manifest.fresh?.plugins) {
1220
+ for (const plugin of manifest.fresh.plugins) {
1221
+ let entryPath = editor.pathJoin(packageDir, plugin.entry);
1222
+
1223
+ // Try .js fallback if .ts doesn't exist
1224
+ if (!editor.fileExists(entryPath) && entryPath.endsWith(".ts")) {
1225
+ const jsPath = entryPath.replace(/\.ts$/, ".js");
1226
+ if (editor.fileExists(jsPath)) {
1227
+ entryPath = jsPath;
1228
+ }
1229
+ }
1230
+
1231
+ if (editor.fileExists(entryPath)) {
1232
+ editor.debug(`[pkg] Loading bundle plugin: ${plugin.entry}`);
1233
+ await editor.loadPlugin(entryPath);
1234
+ } else {
1235
+ editor.warn(`[pkg] Bundle plugin not found: ${plugin.entry}`);
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // Reload themes if bundle contains any (uses same format as theme-packs)
1241
+ if (manifest.fresh?.themes && manifest.fresh.themes.length > 0) {
1242
+ editor.debug(`[pkg] Bundle contains ${manifest.fresh.themes.length} theme(s), reloading themes`);
1243
+ editor.reloadThemes();
1244
+ }
1245
+
1246
+ // Apply grammar changes
1247
+ editor.reloadGrammars();
1248
+ editor.debug(`[pkg] Bundle loaded: ${bundleName}`);
1249
+ }
1250
+
855
1251
  /**
856
1252
  * Checkout a specific version in a package directory
857
1253
  */
@@ -1126,7 +1522,7 @@ interface PackageListItem {
1126
1522
  stars?: number;
1127
1523
  downloads?: number;
1128
1524
  keywords?: string[];
1129
- packageType: "plugin" | "theme" | "language";
1525
+ packageType: "plugin" | "theme" | "language" | "bundle";
1130
1526
  // For installed packages
1131
1527
  installedPackage?: InstalledPackage;
1132
1528
  // For available packages
@@ -1135,7 +1531,7 @@ interface PackageListItem {
1135
1531
 
1136
1532
  // Focus target types for Tab navigation
1137
1533
  type FocusTarget =
1138
- | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages
1534
+ | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages, 5=Bundles
1139
1535
  | { type: "sync" }
1140
1536
  | { type: "search" }
1141
1537
  | { type: "list" } // Package list (use arrows to navigate)
@@ -1146,7 +1542,7 @@ interface PkgManagerState {
1146
1542
  bufferId: number | null;
1147
1543
  splitId: number | null;
1148
1544
  sourceBufferId: number | null;
1149
- filter: "all" | "installed" | "plugins" | "themes" | "languages";
1545
+ filter: "all" | "installed" | "plugins" | "themes" | "languages" | "bundles";
1150
1546
  searchQuery: string;
1151
1547
  items: PackageListItem[];
1152
1548
  selectedIndex: number;
@@ -1277,9 +1673,10 @@ function buildPackageList(): PackageListItem[] {
1277
1673
  const installedPlugins = getInstalledPackages("plugin");
1278
1674
  const installedThemes = getInstalledPackages("theme");
1279
1675
  const installedLanguages = getInstalledPackages("language");
1676
+ const installedBundles = getInstalledPackages("bundle");
1280
1677
  const installedMap = new Map<string, InstalledPackage>();
1281
1678
 
1282
- for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages]) {
1679
+ for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages, ...installedBundles]) {
1283
1680
  installedMap.set(pkg.name, pkg);
1284
1681
  items.push({
1285
1682
  type: "installed",
@@ -1393,6 +1790,9 @@ function getFilteredItems(): PackageListItem[] {
1393
1790
  case "languages":
1394
1791
  items = items.filter(i => i.packageType === "language");
1395
1792
  break;
1793
+ case "bundles":
1794
+ items = items.filter(i => i.packageType === "bundle");
1795
+ break;
1396
1796
  }
1397
1797
 
1398
1798
  // Apply search (case insensitive)
@@ -1519,6 +1919,7 @@ function buildListViewEntries(): TextPropertyEntry[] {
1519
1919
  { id: "plugins", label: "Plugins" },
1520
1920
  { id: "themes", label: "Themes" },
1521
1921
  { id: "languages", label: "Languages" },
1922
+ { id: "bundles", label: "Bundles" },
1522
1923
  ];
1523
1924
 
1524
1925
  // Build filter buttons with position tracking
@@ -1600,7 +2001,7 @@ function buildListViewEntries(): TextPropertyEntry[] {
1600
2001
  const isSelected = idx === pkgState.selectedIndex;
1601
2002
  const listFocused = pkgState.focus.type === "list";
1602
2003
  const prefix = isSelected && listFocused ? "▸" : " ";
1603
- const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P";
2004
+ const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : item.packageType === "bundle" ? "B" : "P";
1604
2005
  const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name;
1605
2006
  const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`;
1606
2007
  leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: false });
@@ -2006,6 +2407,7 @@ function getFocusOrder(): FocusTarget[] {
2006
2407
  { type: "filter", index: 2 }, // Plugins
2007
2408
  { type: "filter", index: 3 }, // Themes
2008
2409
  { type: "filter", index: 4 }, // Languages
2410
+ { type: "filter", index: 5 }, // Bundles
2009
2411
  { type: "sync" },
2010
2412
  { type: "list" },
2011
2413
  ];
@@ -2089,7 +2491,7 @@ globalThis.pkg_activate = async function(): Promise<void> {
2089
2491
 
2090
2492
  // Handle filter button activation
2091
2493
  if (focus.type === "filter") {
2092
- const filters = ["all", "installed", "plugins", "themes", "languages"] as const;
2494
+ const filters = ["all", "installed", "plugins", "themes", "languages", "bundles"] as const;
2093
2495
  pkgState.filter = filters[focus.index];
2094
2496
  pkgState.selectedIndex = 0;
2095
2497
  pkgState.items = buildPackageList();
@@ -2497,10 +2899,11 @@ editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install
2497
2899
  // are available via the package manager UI and don't need global command palette entries.
2498
2900
 
2499
2901
  // =============================================================================
2500
- // Startup: Load installed language packs
2902
+ // Startup: Load installed language packs and bundles
2501
2903
  // =============================================================================
2502
2904
 
2503
- (async function loadInstalledLanguagePacks() {
2905
+ (async function loadInstalledPackages() {
2906
+ // Load language packs
2504
2907
  const languages = getInstalledPackages("language");
2505
2908
  for (const pkg of languages) {
2506
2909
  if (pkg.manifest) {
@@ -2511,6 +2914,18 @@ editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install
2511
2914
  if (languages.length > 0) {
2512
2915
  editor.debug(`[pkg] Loaded ${languages.length} language pack(s)`);
2513
2916
  }
2917
+
2918
+ // Load bundles
2919
+ const bundles = getInstalledPackages("bundle");
2920
+ for (const pkg of bundles) {
2921
+ if (pkg.manifest) {
2922
+ editor.debug(`[pkg] Loading bundle: ${pkg.name}`);
2923
+ await loadBundle(pkg.path, pkg.manifest);
2924
+ }
2925
+ }
2926
+ if (bundles.length > 0) {
2927
+ editor.debug(`[pkg] Loaded ${bundles.length} bundle(s)`);
2928
+ }
2514
2929
  })();
2515
2930
 
2516
2931
  editor.debug("Package Manager plugin loaded");