vibefast-cli 0.7.7 → 0.7.10

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.
@@ -536,197 +536,231 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
536
536
  }
537
537
  }
538
538
 
539
- // Download and extract (or use bundled zip)
540
- if (!zipPath) {
541
- const result = await withSpinner(
542
- 'Downloading and extracting recipe...',
543
- async () => {
544
- const zip = response.zipData
545
- ? await downloadZip(response.zipData, true)
546
- : await downloadZip(response.signedUrl!);
547
-
548
- const dir = join(tmpdir(), 'vibefast', randomUUID());
549
- await extractZipSafe(zip, dir);
550
-
551
- return { zipPath: zip, extractDir: dir };
552
- },
553
- {
554
- successText: '✓ Recipe downloaded',
539
+ const fallbackZip = await getBundledRecipeZipPath(feature);
540
+ let attemptedFallback = false;
541
+ let installedManifest: (RecipeManifest & { files?: { source: string; destination: string }[] }) | null = null;
542
+ let installedEnvGroups: EnvGroup[] = [];
543
+ let installedEnvAttention: string[] = [];
544
+ let installedCopiedFiles: string[] = [];
545
+ let installedNavInserted = false;
546
+ let installedNavHref: string | undefined;
547
+ let installedNavLabel: string | undefined;
548
+
549
+ // Everything below runs inside a retry loop so we can fall back to bundled recipes
550
+ retry_install: while (true) {
551
+ try {
552
+ // Download and extract (or use bundled zip)
553
+ if (!zipPath) {
554
+ const result = await withSpinner(
555
+ 'Downloading and extracting recipe...',
556
+ async () => {
557
+ const zip = response.zipData
558
+ ? await downloadZip(response.zipData, true)
559
+ : await downloadZip(response.signedUrl!);
560
+
561
+ const dir = join(tmpdir(), 'vibefast', randomUUID());
562
+ await extractZipSafe(zip, dir);
563
+
564
+ return { zipPath: zip, extractDir: dir };
565
+ },
566
+ {
567
+ successText: '✓ Recipe downloaded',
568
+ }
569
+ );
570
+ zipPath = result.zipPath;
571
+ extractDir = result.extractDir;
572
+ } else {
573
+ // Bundled zip path - extract to temp location
574
+ extractDir = join(tmpdir(), 'vibefast', randomUUID());
575
+ await extractZipSafe(zipPath, extractDir);
555
576
  }
556
- );
557
- zipPath = result.zipPath;
558
- extractDir = result.extractDir;
559
- } else {
560
- // Bundled zip path - extract to temp location
561
- extractDir = join(tmpdir(), 'vibefast', randomUUID());
562
- await extractZipSafe(zipPath, extractDir);
563
- }
564
577
 
565
- // Locate manifest (some archives may nest recipe.json)
566
- const findManifest = async (dir: string): Promise<string | null> => {
567
- const entries = await import('fs/promises').then((m) =>
568
- m.readdir(dir, { withFileTypes: true }),
569
- );
570
- for (const entry of entries) {
571
- const full = join(dir, entry.name);
572
- if (entry.isFile() && entry.name === 'recipe.json') return full;
573
- if (entry.isDirectory()) {
574
- const found = await findManifest(full);
575
- if (found) return found;
578
+ // Locate manifest (some archives may nest recipe.json)
579
+ const findManifest = async (dir: string): Promise<string | null> => {
580
+ const entries = await import('fs/promises').then((m) =>
581
+ m.readdir(dir, { withFileTypes: true }),
582
+ );
583
+ for (const entry of entries) {
584
+ const full = join(dir, entry.name);
585
+ if (entry.isFile() && entry.name === 'recipe.json') return full;
586
+ if (entry.isDirectory()) {
587
+ const found = await findManifest(full);
588
+ if (found) return found;
589
+ }
590
+ }
591
+ return null;
592
+ };
593
+
594
+ let manifestPath = join(extractDir, 'recipe.json');
595
+ if (!(await exists(manifestPath))) {
596
+ const found = await findManifest(extractDir);
597
+ if (!found) {
598
+ throw new Error(`recipe.json not found in ${extractDir}`);
599
+ }
600
+ manifestPath = found;
576
601
  }
577
- }
578
- return null;
579
- };
580
-
581
- let manifestPath = join(extractDir, 'recipe.json');
582
- if (!(await exists(manifestPath))) {
583
- const found = await findManifest(extractDir);
584
- if (!found) {
585
- throw new Error(`recipe.json not found in ${extractDir}`);
586
- }
587
- manifestPath = found;
588
- }
589
602
 
590
- const manifestContent = await readFileContent(manifestPath);
591
- const manifest: RecipeManifest & { files?: { source: string; destination: string }[] } = JSON.parse(manifestContent);
592
- if (!manifest.copy && Array.isArray(manifest.files)) {
593
- manifest.copy = manifest.files.map((file) => ({
594
- from: file.source,
595
- to: file.destination,
596
- }));
597
- }
598
- if (!Array.isArray(manifest.copy)) {
599
- throw new Error('recipe.json is missing a valid "copy" array');
600
- }
601
- const extractRoot = resolve(manifestPath, '..');
602
- const repoRoot = resolve(paths.cwd);
603
- if (manifest.target !== target) {
604
- throw new Error(
605
- `Recipe target mismatch: expected ${target}, got ${manifest.target}`
606
- );
607
- }
603
+ const manifestContent = await readFileContent(manifestPath);
604
+ const manifest: RecipeManifest & { files?: { source: string; destination: string }[] } = JSON.parse(manifestContent);
605
+ if (!manifest.copy && Array.isArray(manifest.files)) {
606
+ manifest.copy = manifest.files.map((file) => ({
607
+ from: file.source,
608
+ to: file.destination,
609
+ }));
610
+ }
611
+ if (!Array.isArray(manifest.copy)) {
612
+ throw new Error('recipe.json is missing a valid "copy" array');
613
+ }
614
+ const extractRoot = resolve(manifestPath, '..');
615
+ const repoRoot = resolve(paths.cwd);
616
+ if (manifest.target !== target) {
617
+ throw new Error(
618
+ `Recipe target mismatch: expected ${target}, got ${manifest.target}`
619
+ );
620
+ }
608
621
 
609
- log.info(`Installing ${manifest.name} v${manifest.version}...`);
610
- const envGroups = groupEnvVars(manifest.env, paths.cwd);
611
- let envAttention: string[] = [];
622
+ log.info(`Installing ${manifest.name} v${manifest.version}...`);
623
+ const envGroups = groupEnvVars(manifest.env, paths.cwd);
624
+ let envAttention: string[] = [];
612
625
 
613
- // Copy files with interactive confirmation
614
- const copiedFiles: string[] = [];
615
- const allConflicts: string[] = [];
616
- const allSkipped: string[] = [];
617
-
618
- for (const copySpec of manifest.copy) {
619
- const srcPath = ensureWithinBase(
620
- extractRoot,
621
- resolve(extractRoot, copySpec.from),
622
- `Recipe file ${copySpec.from}`
623
- );
624
- const destPath = ensureWithinBase(
625
- repoRoot,
626
- resolve(repoRoot, copySpec.to),
627
- `Destination ${copySpec.to}`
628
- );
629
-
630
- log.info(`Copying ${copySpec.from} → ${copySpec.to}`);
631
- const result = await copyTree(srcPath, destPath, {
632
- dryRun: options.dryRun,
633
- force: options.force,
634
- interactive: !options.force && !options.dryRun && !options.yes,
635
- });
636
-
637
- copiedFiles.push(...result.files);
638
- allConflicts.push(...result.conflicts);
639
- allSkipped.push(...result.skipped);
640
- }
641
-
642
- // Show conflict warnings in dry-run mode
643
- if (options.dryRun && allConflicts.length > 0) {
644
- log.plain('');
645
- log.warn(`⚠ ${allConflicts.length} file(s) will be overwritten:`);
646
- allConflicts.slice(0, 5).forEach(f => {
647
- const relativePath = f.replace(paths.cwd + '/', '');
648
- log.plain(` • ${relativePath}`);
649
- });
650
- if (allConflicts.length > 5) {
651
- log.plain(` ... and ${allConflicts.length - 5} more`);
652
- }
653
- log.warn('⚠ Make sure you have committed your changes to Git!');
654
- log.plain('');
655
- }
626
+ // Copy files with interactive confirmation
627
+ const copiedFiles: string[] = [];
628
+ const allConflicts: string[] = [];
629
+ const allSkipped: string[] = [];
630
+
631
+ for (const copySpec of manifest.copy) {
632
+ const srcPath = ensureWithinBase(
633
+ extractRoot,
634
+ resolve(extractRoot, copySpec.from),
635
+ `Recipe file ${copySpec.from}`
636
+ );
637
+ const destPath = ensureWithinBase(
638
+ repoRoot,
639
+ resolve(repoRoot, copySpec.to),
640
+ `Destination ${copySpec.to}`
641
+ );
642
+
643
+ log.info(`Copying ${copySpec.from} → ${copySpec.to}`);
644
+ const result = await copyTree(srcPath, destPath, {
645
+ dryRun: options.dryRun,
646
+ force: options.force,
647
+ interactive: !options.force && !options.dryRun && !options.yes,
648
+ });
649
+
650
+ copiedFiles.push(...result.files);
651
+ allConflicts.push(...result.conflicts);
652
+ allSkipped.push(...result.skipped);
653
+ }
656
654
 
657
- // Show skipped files
658
- if (allSkipped.length > 0) {
659
- log.info(`ℹ Skipped ${allSkipped.length} file(s) (you chose not to overwrite)`);
660
- }
655
+ // Show conflict warnings in dry-run mode
656
+ if (options.dryRun && allConflicts.length > 0) {
657
+ log.plain('');
658
+ log.warn(`⚠ ${allConflicts.length} file(s) will be overwritten:`);
659
+ allConflicts.slice(0, 5).forEach(f => {
660
+ const relativePath = f.replace(paths.cwd + '/', '');
661
+ log.plain(` • ${relativePath}`);
662
+ });
663
+ if (allConflicts.length > 5) {
664
+ log.plain(` ... and ${allConflicts.length - 5} more`);
665
+ }
666
+ log.warn('⚠ Make sure you have committed your changes to Git!');
667
+ log.plain('');
668
+ }
669
+
670
+ // Show skipped files
671
+ if (allSkipped.length > 0) {
672
+ log.info(`ℹ Skipped ${allSkipped.length} file(s) (you chose not to overwrite)`);
673
+ }
661
674
 
662
- // Add watermark if provided
663
- if (response.watermark && !options.dryRun) {
664
- const { writeFileContent, readFileContent } = await import('../core/fsx.js');
665
- for (const file of copiedFiles) {
666
- if (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.jsx')) {
667
- const content = await readFileContent(file);
668
- const watermarked = `// vibefast license: ${response.watermark}\n${content}`;
669
- await writeFileContent(file, watermarked, { force: true });
675
+ // Add watermark if provided
676
+ if (response.watermark && !options.dryRun) {
677
+ const { writeFileContent, readFileContent } = await import('../core/fsx.js');
678
+ for (const file of copiedFiles) {
679
+ if (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.jsx')) {
680
+ const content = await readFileContent(file);
681
+ const watermarked = `// vibefast license: ${response.watermark}\n${content}`;
682
+ await writeFileContent(file, watermarked, { force: true });
683
+ }
684
+ }
670
685
  }
671
- }
672
- }
673
686
 
674
- // Insert nav link
675
- let navInserted = false;
676
- let navHref: string | undefined;
677
- let navLabel: string | undefined;
678
- if (manifest.nav) {
679
- log.info('Adding navigation link...');
680
- const navFile = target === 'native' ? paths.nativeNavFile : paths.webNavFile;
681
- const insertFn = target === 'native' ? insertNavLinkNative : insertNavLinkWeb;
682
- navHref = manifest.nav.href;
683
- navLabel = manifest.nav.label;
684
-
685
- navInserted = await insertFn(navFile, manifest.nav, { dryRun: options.dryRun });
686
- if (navInserted) {
687
- log.success('Navigation link added');
688
- } else {
689
- log.info('Navigation link already exists');
690
- }
691
- }
687
+ // Insert nav link
688
+ let navInserted = false;
689
+ let navHref: string | undefined;
690
+ let navLabel: string | undefined;
691
+ if (manifest.nav) {
692
+ log.info('Adding navigation link...');
693
+ const navFile = target === 'native' ? paths.nativeNavFile : paths.webNavFile;
694
+ const insertFn = target === 'native' ? insertNavLinkNative : insertNavLinkWeb;
695
+ navHref = manifest.nav.href;
696
+ navLabel = manifest.nav.label;
697
+
698
+ navInserted = await insertFn(navFile, manifest.nav, { dryRun: options.dryRun });
699
+ if (navInserted) {
700
+ log.success('Navigation link added');
701
+ } else {
702
+ log.info('Navigation link already exists');
703
+ }
704
+ }
692
705
 
693
- // Hash files and update journal
694
- if (!options.dryRun) {
695
- log.info('Computing file hashes...');
696
- const fileHashes = await hashFiles(copiedFiles, { showProgress: copiedFiles.length > 20 });
697
-
698
- const fileEntries: FileEntry[] = Array.from(fileHashes.entries()).map(([path, hash]) => ({
699
- path,
700
- hash,
701
- }));
702
-
703
- await addEntry(paths.journalFile, {
704
- feature: manifest.name,
705
- target: manifest.target,
706
- files: fileEntries,
707
- insertedNav: navInserted,
708
- navHref,
709
- navLabel,
710
- ts: Date.now(),
711
- manifest: {
712
- version: manifest.version,
713
- manualSteps: manifest.manualSteps,
714
- env: manifest.env,
715
- },
716
- });
717
- }
706
+ // Hash files and update journal
707
+ if (!options.dryRun) {
708
+ log.info('Computing file hashes...');
709
+ const fileHashes = await hashFiles(copiedFiles, { showProgress: copiedFiles.length > 20 });
710
+
711
+ const fileEntries: FileEntry[] = Array.from(fileHashes.entries()).map(([path, hash]) => ({
712
+ path,
713
+ hash,
714
+ }));
715
+
716
+ await addEntry(paths.journalFile, {
717
+ feature: manifest.name,
718
+ target: manifest.target,
719
+ files: fileEntries,
720
+ insertedNav: navInserted,
721
+ navHref,
722
+ navLabel,
723
+ ts: Date.now(),
724
+ manifest: {
725
+ version: manifest.version,
726
+ manualSteps: manifest.manualSteps,
727
+ env: manifest.env,
728
+ },
729
+ });
730
+ }
718
731
 
719
- log.success(`${manifest.name} installed successfully!`);
720
- log.info(`Files added: ${copiedFiles.length}`);
732
+ // capture results for post steps outside the loop
733
+ installedManifest = manifest;
734
+ installedEnvGroups = envGroups;
735
+ installedEnvAttention = envAttention;
736
+ installedCopiedFiles = copiedFiles;
737
+ installedNavInserted = navInserted;
738
+ installedNavHref = navHref;
739
+ installedNavLabel = navLabel;
740
+
741
+ log.success(`${manifest.name} installed successfully!`);
742
+ log.info(`Files added: ${copiedFiles.length}`);
743
+ break retry_install;
744
+ } catch (err: any) {
745
+ if (!attemptedFallback && fallbackZip) {
746
+ attemptedFallback = true;
747
+ zipPath = fallbackZip;
748
+ extractDir = null;
749
+ log.warn(`⚠ Encountered error "${err.message}". Retrying with bundled recipe...`);
750
+ continue retry_install;
751
+ }
752
+ throw err;
753
+ }
754
+ }
721
755
 
722
756
  // Auto-install dependencies
723
- if (manifest.dependencies && !options.dryRun) {
757
+ if (installedManifest && installedManifest.dependencies && !options.dryRun) {
724
758
  log.plain('');
725
759
  log.warn('⚠ This feature requires additional packages');
726
760
  log.plain('');
727
761
 
728
- if (manifest.target === 'native' && manifest.dependencies.expo) {
729
- const packages = manifest.dependencies.expo;
762
+ if (installedManifest.target === 'native' && installedManifest.dependencies.expo) {
763
+ const packages = installedManifest.dependencies.expo;
730
764
 
731
765
  log.info('📦 Required packages:');
732
766
  packages.forEach(pkg => {
@@ -760,8 +794,8 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
760
794
  log.info('💡 Expo will automatically pick compatible versions');
761
795
  }
762
796
 
763
- } else if (manifest.target === 'web' && manifest.dependencies.npm) {
764
- const packages = manifest.dependencies.npm;
797
+ } else if (installedManifest.target === 'web' && installedManifest.dependencies.npm) {
798
+ const packages = installedManifest.dependencies.npm;
765
799
 
766
800
  log.info('📦 Required packages:');
767
801
  packages.forEach(pkg => {
@@ -808,7 +842,7 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
808
842
  }
809
843
 
810
844
  // Auto-setup Vosk model for wake-word
811
- if (manifest.name === 'wake-word' && !options.dryRun) {
845
+ if (installedManifest?.name === 'wake-word' && !options.dryRun) {
812
846
  const { setupVoskModel } = await import('../core/vosk.js');
813
847
  log.plain('');
814
848
  await withSpinner(
@@ -822,20 +856,21 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
822
856
  );
823
857
  }
824
858
 
825
- if (manifest.configuration?.env) {
826
- await applyEnvConfiguration(paths, manifest.configuration.env, options);
859
+ if (installedManifest?.configuration?.env) {
860
+ await applyEnvConfiguration(paths, installedManifest.configuration.env, options);
827
861
  }
828
862
 
829
863
  // Show manual steps with smart detection
830
- if (manifest.manualSteps && !options.dryRun) {
864
+ if (installedManifest?.manualSteps && !options.dryRun) {
831
865
  const { readFileContent, exists } = await import('../core/fsx.js');
832
866
  const { join } = await import('path');
867
+ const normalize = (text: string) => text.replace(/\\n/g, '\n');
833
868
 
834
869
  // Check which steps might already be done
835
- const pendingSteps: typeof manifest.manualSteps = [];
870
+ const pendingSteps: typeof installedManifest.manualSteps = [];
836
871
  const completedSteps: string[] = [];
837
872
 
838
- for (const step of manifest.manualSteps) {
873
+ for (const step of installedManifest.manualSteps) {
839
874
  let alreadyDone = false;
840
875
 
841
876
  // Check if file modification is already done
@@ -862,12 +897,10 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
862
897
  log.plain('');
863
898
  log.warn('⚠ MANUAL STEPS REQUIRED:');
864
899
  log.plain('');
865
- log.plain('This feature requires some manual configuration:');
866
- log.plain('');
867
900
 
868
901
  pendingSteps.forEach((step, index) => {
869
902
  log.plain(`Step ${index + 1}: ${step.title}`);
870
- log.plain(` ${step.description}`);
903
+ log.plain(` ${normalize(step.description || '')}`);
871
904
  if (step.link) {
872
905
  log.plain(` 🔗 ${step.link}`);
873
906
  }
@@ -875,12 +908,18 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
875
908
  log.plain(` 📝 File: ${step.file}`);
876
909
  }
877
910
  if (step.content) {
878
- log.plain(` Add: ${step.content}`);
911
+ log.plain(' Add:');
912
+ log.plain(
913
+ normalize(step.content)
914
+ .split('\n')
915
+ .map(line => ` ${line}`)
916
+ .join('\n')
917
+ );
879
918
  }
880
919
  log.plain('');
881
920
  });
882
921
 
883
- log.info(`💡 Run 'vf checklist ${manifest.name}' to see these steps again`);
922
+ log.info(`💡 Run 'vf checklist ${installedManifest.name}' to see these steps again`);
884
923
  log.plain('');
885
924
  }
886
925
 
@@ -890,8 +929,8 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
890
929
  }
891
930
  }
892
931
 
893
- if (envGroups.length > 0) {
894
- const additions = await ensureEnvVarsForGroups(envGroups, options);
932
+ if (installedEnvGroups.length > 0) {
933
+ const additions = await ensureEnvVarsForGroups(installedEnvGroups, options);
895
934
 
896
935
  if (additions.length > 0) {
897
936
  log.plain('');
@@ -904,29 +943,29 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
904
943
  log.plain('');
905
944
  }
906
945
 
907
- envAttention = await reportEnvStatus(envGroups, options);
946
+ installedEnvAttention = await reportEnvStatus(installedEnvGroups, options);
908
947
  }
909
948
 
910
949
  // Post-install message
911
- if (manifest.postInstall?.message && !options.dryRun) {
950
+ if (installedManifest?.postInstall?.message && !options.dryRun) {
912
951
  log.plain('');
913
- log.info(manifest.postInstall.message);
952
+ log.info(installedManifest.postInstall.message);
914
953
  }
915
954
 
916
955
  // Final summary of what needs user attention
917
956
  if (!options.dryRun) {
918
957
  const needsAttention: string[] = [];
919
958
 
920
- if (envGroups.length > 0 && envAttention.length > 0) {
921
- needsAttention.push(...envAttention);
959
+ if (installedEnvGroups.length > 0 && installedEnvAttention.length > 0) {
960
+ needsAttention.push(...installedEnvAttention);
922
961
  }
923
962
 
924
963
  // Check manual steps
925
- if (manifest.manualSteps) {
964
+ if (installedManifest?.manualSteps) {
926
965
  const { readFileContent, exists } = await import('../core/fsx.js');
927
966
  const { join } = await import('path');
928
967
 
929
- for (const step of manifest.manualSteps) {
968
+ for (const step of installedManifest.manualSteps) {
930
969
  let alreadyDone = false;
931
970
 
932
971
  if (step.file && step.content) {
@@ -958,7 +997,7 @@ async function applyEnvConfiguration(paths: ReturnType<typeof getPaths>, envConf
958
997
  log.plain(` ${index + 1}. ${item}`);
959
998
  });
960
999
  log.plain('');
961
- log.info(`💡 Run 'vf checklist ${manifest.name}' for detailed instructions`);
1000
+ log.info(`💡 Run 'vf checklist ${installedManifest?.name ?? feature}' for detailed instructions`);
962
1001
  log.plain('');
963
1002
  } else {
964
1003
  log.plain('');
@@ -106,9 +106,11 @@ export const checklistCommand = new Command('checklist')
106
106
  log.info(`Manual setup steps for ${feature}:`);
107
107
  log.plain('');
108
108
 
109
+ const normalize = (text: string) => text.replace(/\\n/g, '\n');
110
+
109
111
  entry.manifest.manualSteps.forEach((step: any, index: number) => {
110
112
  log.plain(`Step ${index + 1}: ${step.title}`);
111
- log.plain(` ${step.description}`);
113
+ log.plain(` ${normalize(step.description || '')}`);
112
114
  if (step.link) {
113
115
  log.plain(` 🔗 ${step.link}`);
114
116
  }
@@ -116,7 +118,13 @@ export const checklistCommand = new Command('checklist')
116
118
  log.plain(` 📝 File: ${step.file}`);
117
119
  }
118
120
  if (step.content) {
119
- log.plain(` Add: ${step.content}`);
121
+ log.plain(' Add:');
122
+ log.plain(
123
+ normalize(step.content)
124
+ .split('\n')
125
+ .map((line: string) => ` ${line}`)
126
+ .join('\n')
127
+ );
120
128
  }
121
129
  log.plain('');
122
130
  });