@getjack/jack 0.1.17 → 0.1.20

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 (111) hide show
  1. package/package.json +2 -5
  2. package/src/commands/clone.ts +62 -40
  3. package/src/commands/down.ts +11 -1
  4. package/src/commands/init.ts +21 -2
  5. package/src/commands/services.ts +85 -4
  6. package/src/commands/sync.ts +9 -0
  7. package/src/commands/update.ts +10 -0
  8. package/src/lib/agents.ts +3 -1
  9. package/src/lib/auth/ensure-auth.test.ts +3 -3
  10. package/src/lib/control-plane.ts +77 -1
  11. package/src/lib/deploy-upload.ts +26 -1
  12. package/src/lib/hooks.ts +232 -1
  13. package/src/lib/managed-deploy.ts +37 -6
  14. package/src/lib/output.ts +21 -3
  15. package/src/lib/progress.ts +231 -0
  16. package/src/lib/project-list.ts +6 -1
  17. package/src/lib/project-operations.ts +117 -62
  18. package/src/lib/project-resolver.ts +1 -1
  19. package/src/lib/services/db-create.ts +6 -3
  20. package/src/lib/version-check.ts +14 -0
  21. package/src/lib/wrangler-config.ts +190 -0
  22. package/src/lib/zip-packager.ts +74 -7
  23. package/src/lib/zip-utils.ts +38 -0
  24. package/src/templates/index.ts +1 -1
  25. package/src/templates/types.ts +16 -0
  26. package/templates/CLAUDE.md +103 -0
  27. package/templates/miniapp/.jack.json +1 -3
  28. package/templates/saas/.jack.json +154 -0
  29. package/templates/saas/AGENTS.md +333 -0
  30. package/templates/saas/bun.lock +925 -0
  31. package/templates/saas/components.json +21 -0
  32. package/templates/saas/index.html +12 -0
  33. package/templates/saas/package.json +75 -0
  34. package/templates/saas/public/icon.png +0 -0
  35. package/templates/saas/public/og.png +0 -0
  36. package/templates/saas/schema.sql +73 -0
  37. package/templates/saas/src/auth.ts +77 -0
  38. package/templates/saas/src/client/App.tsx +63 -0
  39. package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
  40. package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
  41. package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
  42. package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
  43. package/templates/saas/src/client/components/ui/alert.tsx +60 -0
  44. package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
  45. package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
  46. package/templates/saas/src/client/components/ui/badge.tsx +39 -0
  47. package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
  48. package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
  49. package/templates/saas/src/client/components/ui/button.tsx +60 -0
  50. package/templates/saas/src/client/components/ui/card.tsx +75 -0
  51. package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
  52. package/templates/saas/src/client/components/ui/chart.tsx +326 -0
  53. package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
  54. package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
  55. package/templates/saas/src/client/components/ui/command.tsx +159 -0
  56. package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
  57. package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
  58. package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
  59. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
  60. package/templates/saas/src/client/components/ui/empty.tsx +94 -0
  61. package/templates/saas/src/client/components/ui/field.tsx +232 -0
  62. package/templates/saas/src/client/components/ui/form.tsx +152 -0
  63. package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
  64. package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
  65. package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
  66. package/templates/saas/src/client/components/ui/input.tsx +21 -0
  67. package/templates/saas/src/client/components/ui/item.tsx +172 -0
  68. package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
  69. package/templates/saas/src/client/components/ui/label.tsx +21 -0
  70. package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
  71. package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
  72. package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
  73. package/templates/saas/src/client/components/ui/popover.tsx +42 -0
  74. package/templates/saas/src/client/components/ui/progress.tsx +26 -0
  75. package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
  76. package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
  77. package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
  78. package/templates/saas/src/client/components/ui/select.tsx +173 -0
  79. package/templates/saas/src/client/components/ui/separator.tsx +28 -0
  80. package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
  81. package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
  82. package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
  83. package/templates/saas/src/client/components/ui/slider.tsx +58 -0
  84. package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
  85. package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
  86. package/templates/saas/src/client/components/ui/switch.tsx +28 -0
  87. package/templates/saas/src/client/components/ui/table.tsx +90 -0
  88. package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
  89. package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
  90. package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
  91. package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
  92. package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
  93. package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
  94. package/templates/saas/src/client/hooks/useAuth.ts +14 -0
  95. package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
  96. package/templates/saas/src/client/index.css +165 -0
  97. package/templates/saas/src/client/lib/auth-client.ts +7 -0
  98. package/templates/saas/src/client/lib/plans.ts +82 -0
  99. package/templates/saas/src/client/lib/utils.ts +6 -0
  100. package/templates/saas/src/client/main.tsx +15 -0
  101. package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
  102. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
  103. package/templates/saas/src/client/pages/HomePage.tsx +285 -0
  104. package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
  105. package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
  106. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
  107. package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
  108. package/templates/saas/src/index.ts +208 -0
  109. package/templates/saas/tsconfig.json +18 -0
  110. package/templates/saas/vite.config.ts +14 -0
  111. package/templates/saas/wrangler.jsonc +20 -0
package/src/lib/hooks.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import * as readline from "node:readline";
4
4
  import type { HookAction } from "../templates/types";
5
5
  import { applyJsonWrite } from "./json-edit";
6
- import { getSavedSecrets } from "./secrets";
6
+ import { getSavedSecrets, saveSecrets } from "./secrets";
7
7
  import { restoreTty } from "./tty";
8
8
 
9
9
  /**
@@ -379,9 +379,87 @@ const actionHandlers: {
379
379
  require: async (action, context, options) => {
380
380
  const ui = options.output ?? noopOutput;
381
381
  const interactive = options.interactive !== false;
382
+ const onMissing = action.onMissing ?? "fail";
383
+
382
384
  if (action.source === "secret") {
383
385
  const result = await checkSecretExists(action.key, context.projectDir);
386
+ if (result.exists) {
387
+ // Found existing secret - show feedback for prompt/generate modes
388
+ if (onMissing === "prompt" || onMissing === "generate") {
389
+ ui.success(`Using saved ${action.key}`);
390
+ }
391
+ return true;
392
+ }
393
+
394
+ // Secret doesn't exist - handle based on onMissing mode
384
395
  if (!result.exists) {
396
+ // Handle onMissing: "generate" - run command and save output
397
+ if (onMissing === "generate" && action.generateCommand) {
398
+ const message = action.message ?? `Generating ${action.key}...`;
399
+ ui.info(message);
400
+
401
+ try {
402
+ const proc = Bun.spawn(["sh", "-c", action.generateCommand], {
403
+ stdout: "pipe",
404
+ stderr: "pipe",
405
+ });
406
+ await proc.exited;
407
+
408
+ if (proc.exitCode === 0) {
409
+ const stdout = await new Response(proc.stdout).text();
410
+ const value = stdout.trim();
411
+ if (value) {
412
+ await saveSecrets([{ key: action.key, value, source: "generated" }]);
413
+ ui.success(`Generated ${action.key}`);
414
+ return true;
415
+ }
416
+ }
417
+ ui.error(`Failed to generate ${action.key}`);
418
+ return false;
419
+ } catch {
420
+ ui.error(`Failed to run: ${action.generateCommand}`);
421
+ return false;
422
+ }
423
+ }
424
+
425
+ // Handle onMissing: "prompt" - ask user for value
426
+ if (onMissing === "prompt") {
427
+ if (!interactive) {
428
+ // Fall back to fail behavior in non-interactive mode
429
+ const message = action.message ?? `Missing required secret: ${action.key}`;
430
+ ui.error(message);
431
+ ui.info(`Run: jack secrets add ${action.key}`);
432
+ if (action.setupUrl) {
433
+ ui.info(`Setup: ${action.setupUrl}`);
434
+ }
435
+ return false;
436
+ }
437
+
438
+ // Show setup info and go straight to prompt (URLs are clickable in most terminals)
439
+ const promptMsg = action.promptMessage ?? `${action.key}:`;
440
+ console.error("");
441
+ if (action.message) {
442
+ console.error(` ${action.message}`);
443
+ }
444
+ if (action.setupUrl) {
445
+ console.error(` Get it at: \x1b[36m${action.setupUrl}\x1b[0m`);
446
+ }
447
+ console.error("");
448
+
449
+ const { isCancel, text } = await import("@clack/prompts");
450
+ const value = await text({ message: promptMsg });
451
+
452
+ if (isCancel(value) || !value.trim()) {
453
+ ui.warn(`Skipped ${action.key}`);
454
+ return false;
455
+ }
456
+
457
+ await saveSecrets([{ key: action.key, value: value.trim(), source: "prompted" }]);
458
+ ui.success(`Saved ${action.key}`);
459
+ return true;
460
+ }
461
+
462
+ // Default: onMissing: "fail"
385
463
  const message = action.message ?? `Missing required secret: ${action.key}`;
386
464
  ui.error(message);
387
465
  ui.info(`Run: jack secrets add ${action.key}`);
@@ -425,6 +503,14 @@ const actionHandlers: {
425
503
  // Use multi-line input for JSON validation (handles paste from Farcaster etc.)
426
504
  if (action.validate === "json" || action.validate === "accountAssociation") {
427
505
  rawValue = await readMultilineJson(action.message);
506
+ } else if (action.secret) {
507
+ // Use password() for sensitive input (masks the value)
508
+ const { isCancel, password } = await import("@clack/prompts");
509
+ const result = await password({ message: action.message });
510
+ if (isCancel(result)) {
511
+ return true;
512
+ }
513
+ rawValue = result;
428
514
  } else {
429
515
  const { isCancel, text } = await import("@clack/prompts");
430
516
  const result = await text({ message: action.message });
@@ -538,6 +624,151 @@ const actionHandlers: {
538
624
  await proc.exited;
539
625
  return proc.exitCode === 0;
540
626
  },
627
+ "stripe-setup": async (action, context, options) => {
628
+ const ui = options.output ?? noopOutput;
629
+
630
+ // Get Stripe API key from saved secrets
631
+ const savedSecrets = await getSavedSecrets();
632
+ const stripeKey = savedSecrets.STRIPE_SECRET_KEY;
633
+
634
+ if (!stripeKey) {
635
+ ui.error("Missing STRIPE_SECRET_KEY - run the secret prompt first");
636
+ return false;
637
+ }
638
+
639
+ const message = action.message ?? "Setting up Stripe products and prices...";
640
+ ui.info(message);
641
+
642
+ // Helper to make Stripe API requests
643
+ async function stripeRequest(
644
+ method: string,
645
+ endpoint: string,
646
+ body?: Record<string, string>,
647
+ ): Promise<{ ok: boolean; data?: Record<string, unknown>; error?: string }> {
648
+ const url = `https://api.stripe.com/v1${endpoint}`;
649
+ const headers: Record<string, string> = {
650
+ Authorization: `Bearer ${stripeKey}`,
651
+ };
652
+
653
+ const fetchOptions: RequestInit = { method, headers };
654
+ if (body) {
655
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
656
+ fetchOptions.body = new URLSearchParams(body).toString();
657
+ }
658
+
659
+ try {
660
+ const response = await fetch(url, fetchOptions);
661
+ const data = (await response.json()) as Record<string, unknown>;
662
+
663
+ if (!response.ok) {
664
+ const error = data.error as { message?: string } | undefined;
665
+ return { ok: false, error: error?.message ?? "Stripe API error" };
666
+ }
667
+ return { ok: true, data };
668
+ } catch (err) {
669
+ return { ok: false, error: String(err) };
670
+ }
671
+ }
672
+
673
+ // Search for existing price by lookup_key
674
+ async function findPriceByLookupKey(lookupKey: string): Promise<string | null> {
675
+ const result = await stripeRequest(
676
+ "GET",
677
+ `/prices?lookup_keys[]=${encodeURIComponent(lookupKey)}&active=true`,
678
+ );
679
+ if (result.ok && result.data) {
680
+ const prices = result.data.data as Array<{ id: string }>;
681
+ if (prices.length > 0) {
682
+ return prices[0].id;
683
+ }
684
+ }
685
+ return null;
686
+ }
687
+
688
+ // Create a new product
689
+ async function createProduct(name: string, description?: string): Promise<string | null> {
690
+ const body: Record<string, string> = { name };
691
+ if (description) {
692
+ body.description = description;
693
+ }
694
+ const result = await stripeRequest("POST", "/products", body);
695
+ if (result.ok && result.data) {
696
+ return result.data.id as string;
697
+ }
698
+ return null;
699
+ }
700
+
701
+ // Create a new price with lookup_key
702
+ async function createPrice(
703
+ productId: string,
704
+ amount: number,
705
+ interval: "month" | "year",
706
+ lookupKey: string,
707
+ ): Promise<string | null> {
708
+ const result = await stripeRequest("POST", "/prices", {
709
+ product: productId,
710
+ unit_amount: String(amount),
711
+ currency: "usd",
712
+ "recurring[interval]": interval,
713
+ lookup_key: lookupKey,
714
+ });
715
+ if (result.ok && result.data) {
716
+ return result.data.id as string;
717
+ }
718
+ return null;
719
+ }
720
+
721
+ const secretsToSave: Array<{ key: string; value: string; source: string }> = [];
722
+
723
+ for (const plan of action.plans) {
724
+ const lookupKey = `jack_${plan.name.toLowerCase()}_${plan.interval}`;
725
+
726
+ // Check if price key already exists in secrets (manual override)
727
+ if (savedSecrets[plan.priceKey]) {
728
+ ui.success(`Using existing ${plan.priceKey}`);
729
+ continue;
730
+ }
731
+
732
+ // Search for existing price by lookup_key
733
+ ui.info(`Checking for existing ${plan.name} price...`);
734
+ let priceId = await findPriceByLookupKey(lookupKey);
735
+
736
+ if (priceId) {
737
+ ui.success(`Found existing ${plan.name} price: ${priceId}`);
738
+ } else {
739
+ // Create product and price
740
+ ui.info(`Creating ${plan.name} product and price...`);
741
+
742
+ const productId = await createProduct(plan.name, plan.description ?? `${plan.name} plan`);
743
+ if (!productId) {
744
+ ui.error(`Failed to create ${plan.name} product`);
745
+ return false;
746
+ }
747
+
748
+ priceId = await createPrice(productId, plan.amount, plan.interval, lookupKey);
749
+ if (!priceId) {
750
+ ui.error(`Failed to create ${plan.name} price`);
751
+ return false;
752
+ }
753
+
754
+ ui.success(`Created ${plan.name}: ${priceId}`);
755
+ }
756
+
757
+ secretsToSave.push({
758
+ key: plan.priceKey,
759
+ value: priceId,
760
+ source: "stripe-setup",
761
+ });
762
+ }
763
+
764
+ // Save all price IDs to secrets
765
+ if (secretsToSave.length > 0) {
766
+ await saveSecrets(secretsToSave);
767
+ ui.success("Saved Stripe price IDs to secrets");
768
+ }
769
+
770
+ return true;
771
+ },
541
772
  };
542
773
 
543
774
  async function executeAction(
@@ -4,12 +4,15 @@
4
4
  * Isolates managed deployment logic from BYO (wrangler) path.
5
5
  */
6
6
 
7
+ import { stat } from "node:fs/promises";
7
8
  import { validateBindings } from "./binding-validator.ts";
8
9
  import { buildProject, parseWranglerConfig } from "./build-helper.ts";
9
10
  import { createManagedProject, syncProjectTags, uploadSourceSnapshot } from "./control-plane.ts";
10
11
  import { debug } from "./debug.ts";
11
12
  import { uploadDeployment } from "./deploy-upload.ts";
12
13
  import { JackError, JackErrorCode } from "./errors.ts";
14
+ import { formatSize } from "./format.ts";
15
+ import { createFileCountProgress, createUploadProgress } from "./progress.ts";
13
16
  import type { OperationReporter } from "./project-operations.ts";
14
17
  import { getProjectTags } from "./tags.ts";
15
18
  import { Events, track } from "./telemetry.ts";
@@ -113,14 +116,42 @@ export async function deployCodeToManagedProject(
113
116
  );
114
117
  }
115
118
 
116
- // Step 3: Package artifacts
117
- reporter?.start("Packaging artifacts...");
118
- pkg = await packageForDeploy(projectPath, buildOutput, config);
119
- reporter?.stop();
119
+ // Step 3: Package artifacts with file-count progress
120
+ reporter?.stop(); // Stop reporter spinner, we'll use our own progress
121
+ const packagingProgress = createFileCountProgress({ label: "Packaging" });
122
+ packagingProgress.start();
123
+ pkg = await packageForDeploy({
124
+ projectPath,
125
+ buildOutput,
126
+ config,
127
+ onProgress: (current, total) => packagingProgress.update(current, total),
128
+ });
129
+ packagingProgress.complete();
120
130
  reporter?.success("Packaged artifacts");
121
131
 
122
132
  // Step 4: Upload to control plane
123
- reporter?.start("Uploading to jack cloud...");
133
+ // Calculate total upload size for progress display
134
+ const fileSizes = await Promise.all([
135
+ stat(pkg.bundleZipPath).then((s) => s.size),
136
+ stat(pkg.sourceZipPath).then((s) => s.size),
137
+ stat(pkg.manifestPath).then((s) => s.size),
138
+ pkg.schemaPath ? stat(pkg.schemaPath).then((s) => s.size) : Promise.resolve(0),
139
+ pkg.secretsPath ? stat(pkg.secretsPath).then((s) => s.size) : Promise.resolve(0),
140
+ pkg.assetsZipPath ? stat(pkg.assetsZipPath).then((s) => s.size) : Promise.resolve(0),
141
+ ]);
142
+ const totalUploadSize = fileSizes.reduce((sum, size) => sum + size, 0);
143
+ debug(`Upload size: ${formatSize(totalUploadSize)}`);
144
+
145
+ // Stop the reporter spinner - we'll use our own progress display
146
+ reporter?.stop();
147
+
148
+ // Use custom progress with pulsing bar (since fetch doesn't support upload progress)
149
+ const uploadProgress = createUploadProgress({
150
+ totalSize: totalUploadSize,
151
+ label: "Deploying to jack cloud",
152
+ });
153
+ uploadProgress.start();
154
+
124
155
  const result = await uploadDeployment({
125
156
  projectId,
126
157
  bundleZipPath: pkg.bundleZipPath,
@@ -132,7 +163,7 @@ export async function deployCodeToManagedProject(
132
163
  assetManifest: pkg.assetManifest ?? undefined,
133
164
  });
134
165
 
135
- reporter?.stop();
166
+ uploadProgress.complete();
136
167
  reporter?.success("Deployed to jack cloud");
137
168
 
138
169
  // Track success
package/src/lib/output.ts CHANGED
@@ -2,6 +2,19 @@ import yoctoSpinner from "yocto-spinner";
2
2
 
3
3
  const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
4
4
 
5
+ /**
6
+ * ANSI color codes for terminal output
7
+ */
8
+ export const colors = {
9
+ green: isColorEnabled ? "\x1b[32m" : "",
10
+ red: isColorEnabled ? "\x1b[31m" : "",
11
+ yellow: isColorEnabled ? "\x1b[33m" : "",
12
+ cyan: isColorEnabled ? "\x1b[36m" : "",
13
+ dim: isColorEnabled ? "\x1b[90m" : "",
14
+ bold: isColorEnabled ? "\x1b[1m" : "",
15
+ reset: isColorEnabled ? "\x1b[0m" : "",
16
+ };
17
+
5
18
  let currentSpinner: ReturnType<typeof yoctoSpinner> | null = null;
6
19
 
7
20
  /**
@@ -95,8 +108,10 @@ export function item(message: string): void {
95
108
  console.error(` ${message}`);
96
109
  }
97
110
 
98
- // Random neon purple for cyberpunk styling
99
- function getRandomPurple(): string {
111
+ /**
112
+ * Random neon purple for cyberpunk styling
113
+ */
114
+ export function getRandomPurple(): string {
100
115
  const purples = [177, 165, 141, 129];
101
116
  const colorCode = purples[Math.floor(Math.random() * purples.length)];
102
117
  return `\x1b[38;5;${colorCode}m`;
@@ -117,12 +132,15 @@ export function box(title: string, lines: string[]): void {
117
132
  const fill = "▓".repeat(innerWidth);
118
133
  const gradient = "░".repeat(innerWidth);
119
134
 
135
+ // Pad plain text first, then apply colors (ANSI codes break padEnd calculation)
120
136
  const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
137
+ const padTitle = (text: string) =>
138
+ ` ${bold}${text}${reset}${purple}${" ".repeat(maxLen - text.length)} `;
121
139
 
122
140
  console.error("");
123
141
  console.error(` ${purple}╔${bar}╗${reset}`);
124
142
  console.error(` ${purple}║${fill}║${reset}`);
125
- console.error(` ${purple}║${pad(bold + title + reset + purple)}║${reset}`);
143
+ console.error(` ${purple}║${padTitle(title)}║${reset}`);
126
144
  console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
127
145
  for (const line of lines) {
128
146
  console.error(` ${purple}║${pad(line)}║${reset}`);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Progress tracker with delayed bar display
3
+ * Shows a simple spinner initially, then reveals a progress bar
4
+ * after a configurable delay (aligned with SPIRIT.md principles)
5
+ */
6
+
7
+ import { formatSize } from "./format.ts";
8
+ import { colors, getRandomPurple } from "./output.ts";
9
+
10
+ export interface ProgressOptions {
11
+ total: number;
12
+ delayMs?: number;
13
+ barWidth?: number;
14
+ label?: string;
15
+ }
16
+
17
+ export interface UploadProgressOptions {
18
+ totalSize: number;
19
+ delayMs?: number;
20
+ barWidth?: number;
21
+ label?: string;
22
+ }
23
+
24
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
+
26
+ /**
27
+ * Creates a progress tracker that shows a spinner initially,
28
+ * then reveals a progress bar after a delay.
29
+ *
30
+ * Usage:
31
+ * const progress = createProgressTracker({ total: fileSize });
32
+ * progress.start();
33
+ * // ... during upload
34
+ * progress.update(bytesUploaded);
35
+ * // ... when done
36
+ * progress.complete();
37
+ */
38
+ export function createProgressTracker(options: ProgressOptions) {
39
+ const { total, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
40
+ const startTime = Date.now();
41
+ const purple = getRandomPurple(); // Pick color once at start
42
+ let frame = 0;
43
+ let intervalId: Timer | null = null;
44
+ let current = 0;
45
+
46
+ function render() {
47
+ const elapsed = Date.now() - startTime;
48
+ const pct = Math.min(Math.round((current / total) * 100), 100);
49
+
50
+ clearLine();
51
+
52
+ if (elapsed < delayMs) {
53
+ // Just spinner for first N seconds
54
+ process.stderr.write(
55
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
56
+ );
57
+ } else {
58
+ // Show progress bar after delay
59
+ const filled = Math.round((pct / 100) * barWidth);
60
+ const empty = barWidth - filled;
61
+ const bar = "▓".repeat(filled) + "░".repeat(empty);
62
+
63
+ process.stderr.write(
64
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${pct}% ${colors.dim}(${formatSize(current)} / ${formatSize(total)})${colors.reset}`,
65
+ );
66
+ }
67
+ }
68
+
69
+ return {
70
+ start() {
71
+ render();
72
+ intervalId = setInterval(render, 150);
73
+ },
74
+
75
+ update(bytes: number) {
76
+ current = bytes;
77
+ },
78
+
79
+ complete() {
80
+ if (intervalId) {
81
+ clearInterval(intervalId);
82
+ intervalId = null;
83
+ }
84
+ clearLine();
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Creates an upload progress indicator for operations where we know the total
91
+ * size but can't track byte-level progress (e.g., fetch uploads).
92
+ *
93
+ * Shows spinner first, then after delay shows an animated bar with size info.
94
+ * The bar pulses to indicate activity without false progress claims.
95
+ */
96
+ export function createUploadProgress(options: UploadProgressOptions) {
97
+ const { totalSize, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
98
+ const startTime = Date.now();
99
+ const purple = getRandomPurple(); // Pick color once at start
100
+ let frame = 0;
101
+ let pulsePos = 0;
102
+ let intervalId: Timer | null = null;
103
+
104
+ function render() {
105
+ const elapsed = Date.now() - startTime;
106
+ const elapsedSec = (elapsed / 1000).toFixed(1);
107
+
108
+ clearLine();
109
+
110
+ if (elapsed < delayMs) {
111
+ // Just spinner for first N seconds
112
+ process.stderr.write(
113
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
114
+ );
115
+ } else {
116
+ // Show pulsing bar after delay (indicates activity without false progress)
117
+ // Create pulsing effect - a bright section that moves across the bar
118
+ const pulseWidth = 5;
119
+ pulsePos = (pulsePos + 1) % (barWidth + pulseWidth);
120
+
121
+ let bar = "";
122
+ for (let i = 0; i < barWidth; i++) {
123
+ const distFromPulse = Math.abs(i - pulsePos);
124
+ if (distFromPulse < pulseWidth) {
125
+ bar += "▓";
126
+ } else {
127
+ bar += "░";
128
+ }
129
+ }
130
+
131
+ process.stderr.write(
132
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${colors.dim}${formatSize(totalSize)} • ${elapsedSec}s${colors.reset}`,
133
+ );
134
+ }
135
+ }
136
+
137
+ return {
138
+ start() {
139
+ if (process.stderr.isTTY) {
140
+ render();
141
+ intervalId = setInterval(render, 150);
142
+ }
143
+ },
144
+
145
+ complete() {
146
+ if (intervalId) {
147
+ clearInterval(intervalId);
148
+ intervalId = null;
149
+ }
150
+ clearLine();
151
+ },
152
+ };
153
+ }
154
+
155
+ export interface FileCountProgressOptions {
156
+ delayMs?: number;
157
+ barWidth?: number;
158
+ label?: string;
159
+ }
160
+
161
+ /**
162
+ * Creates a file-count progress indicator for operations where we know
163
+ * the total file count and can track per-file progress.
164
+ *
165
+ * Shows spinner first, then after delay shows progress bar with file count.
166
+ */
167
+ export function createFileCountProgress(options: FileCountProgressOptions = {}) {
168
+ const { delayMs = 2000, barWidth = 25, label = "Packaging" } = options;
169
+ const startTime = Date.now();
170
+ const purple = getRandomPurple(); // Pick color once at start
171
+ let frame = 0;
172
+ let intervalId: Timer | null = null;
173
+ let current = 0;
174
+ let total = 0;
175
+
176
+ function render() {
177
+ const elapsed = Date.now() - startTime;
178
+
179
+ clearLine();
180
+
181
+ if (elapsed < delayMs) {
182
+ // Just spinner for first N seconds
183
+ process.stderr.write(
184
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
185
+ );
186
+ } else if (total > 0) {
187
+ // Show progress bar with file count after delay
188
+ const pct = Math.min(Math.round((current / total) * 100), 100);
189
+ const filled = Math.round((pct / 100) * barWidth);
190
+ const empty = barWidth - filled;
191
+ const bar = "▓".repeat(filled) + "░".repeat(empty);
192
+
193
+ process.stderr.write(
194
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${colors.dim}(${current}/${total} files)${colors.reset}`,
195
+ );
196
+ } else {
197
+ // No total yet, just show spinner
198
+ process.stderr.write(
199
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
200
+ );
201
+ }
202
+ }
203
+
204
+ return {
205
+ start() {
206
+ if (process.stderr.isTTY) {
207
+ render();
208
+ intervalId = setInterval(render, 150);
209
+ }
210
+ },
211
+
212
+ update(currentFile: number, totalFiles: number) {
213
+ current = currentFile;
214
+ total = totalFiles;
215
+ },
216
+
217
+ complete() {
218
+ if (intervalId) {
219
+ clearInterval(intervalId);
220
+ intervalId = null;
221
+ }
222
+ clearLine();
223
+ },
224
+ };
225
+ }
226
+
227
+ function clearLine() {
228
+ if (process.stderr.isTTY) {
229
+ process.stderr.write("\r\x1b[K");
230
+ }
231
+ }
@@ -338,9 +338,14 @@ export function formatErrorSection(
338
338
  );
339
339
 
340
340
  for (const item of items) {
341
- lines.push(formatProjectLine(item, { indent: 4, tagColorMap }));
341
+ // Don't show errorMessage inline - we'll show a summary hint below
342
+ lines.push(formatProjectLine(item, { indent: 4, tagColorMap, showUrl: false }));
342
343
  }
343
344
 
345
+ // Add hint for resolving errors
346
+ lines.push("");
347
+ lines.push(` ${colors.dim}Run 'jack projects cleanup' to remove stale links${colors.reset}`);
348
+
344
349
  return lines.join("\n");
345
350
  }
346
351