@getjack/jack 0.1.19 → 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 (102) hide show
  1. package/package.json +1 -1
  2. package/src/commands/down.ts +11 -1
  3. package/src/commands/init.ts +19 -6
  4. package/src/lib/agents.ts +3 -1
  5. package/src/lib/auth/ensure-auth.test.ts +3 -3
  6. package/src/lib/control-plane.ts +15 -1
  7. package/src/lib/deploy-upload.ts +26 -1
  8. package/src/lib/hooks.ts +232 -1
  9. package/src/lib/managed-deploy.ts +13 -6
  10. package/src/lib/progress.ts +76 -5
  11. package/src/lib/project-list.ts +6 -1
  12. package/src/lib/project-operations.ts +14 -0
  13. package/src/lib/project-resolver.ts +1 -1
  14. package/src/lib/zip-packager.ts +36 -7
  15. package/src/templates/index.ts +1 -1
  16. package/src/templates/types.ts +16 -0
  17. package/templates/CLAUDE.md +103 -0
  18. package/templates/miniapp/.jack.json +1 -3
  19. package/templates/saas/.jack.json +154 -0
  20. package/templates/saas/AGENTS.md +333 -0
  21. package/templates/saas/bun.lock +925 -0
  22. package/templates/saas/components.json +21 -0
  23. package/templates/saas/index.html +12 -0
  24. package/templates/saas/package.json +75 -0
  25. package/templates/saas/public/icon.png +0 -0
  26. package/templates/saas/public/og.png +0 -0
  27. package/templates/saas/schema.sql +73 -0
  28. package/templates/saas/src/auth.ts +77 -0
  29. package/templates/saas/src/client/App.tsx +63 -0
  30. package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
  31. package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
  32. package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
  33. package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
  34. package/templates/saas/src/client/components/ui/alert.tsx +60 -0
  35. package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
  36. package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
  37. package/templates/saas/src/client/components/ui/badge.tsx +39 -0
  38. package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
  39. package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
  40. package/templates/saas/src/client/components/ui/button.tsx +60 -0
  41. package/templates/saas/src/client/components/ui/card.tsx +75 -0
  42. package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
  43. package/templates/saas/src/client/components/ui/chart.tsx +326 -0
  44. package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
  45. package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
  46. package/templates/saas/src/client/components/ui/command.tsx +159 -0
  47. package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
  48. package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
  49. package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
  50. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
  51. package/templates/saas/src/client/components/ui/empty.tsx +94 -0
  52. package/templates/saas/src/client/components/ui/field.tsx +232 -0
  53. package/templates/saas/src/client/components/ui/form.tsx +152 -0
  54. package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
  55. package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
  56. package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
  57. package/templates/saas/src/client/components/ui/input.tsx +21 -0
  58. package/templates/saas/src/client/components/ui/item.tsx +172 -0
  59. package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
  60. package/templates/saas/src/client/components/ui/label.tsx +21 -0
  61. package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
  62. package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
  63. package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
  64. package/templates/saas/src/client/components/ui/popover.tsx +42 -0
  65. package/templates/saas/src/client/components/ui/progress.tsx +26 -0
  66. package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
  67. package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
  68. package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
  69. package/templates/saas/src/client/components/ui/select.tsx +173 -0
  70. package/templates/saas/src/client/components/ui/separator.tsx +28 -0
  71. package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
  72. package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
  73. package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
  74. package/templates/saas/src/client/components/ui/slider.tsx +58 -0
  75. package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
  76. package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
  77. package/templates/saas/src/client/components/ui/switch.tsx +28 -0
  78. package/templates/saas/src/client/components/ui/table.tsx +90 -0
  79. package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
  80. package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
  81. package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
  82. package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
  83. package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
  84. package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
  85. package/templates/saas/src/client/hooks/useAuth.ts +14 -0
  86. package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
  87. package/templates/saas/src/client/index.css +165 -0
  88. package/templates/saas/src/client/lib/auth-client.ts +7 -0
  89. package/templates/saas/src/client/lib/plans.ts +82 -0
  90. package/templates/saas/src/client/lib/utils.ts +6 -0
  91. package/templates/saas/src/client/main.tsx +15 -0
  92. package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
  93. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
  94. package/templates/saas/src/client/pages/HomePage.tsx +285 -0
  95. package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
  96. package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
  97. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
  98. package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
  99. package/templates/saas/src/index.ts +208 -0
  100. package/templates/saas/tsconfig.json +18 -0
  101. package/templates/saas/vite.config.ts +14 -0
  102. package/templates/saas/wrangler.jsonc +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -10,7 +10,8 @@ import { fetchProjectResources } from "../lib/control-plane.ts";
10
10
  import { promptSelect } from "../lib/hooks.ts";
11
11
  import { managedDown } from "../lib/managed-down.ts";
12
12
  import { error, info, item, output, success, warn } from "../lib/output.ts";
13
- import { type LocalProjectLink, readProjectLink } from "../lib/project-link.ts";
13
+ import { unregisterPath } from "../lib/paths-index.ts";
14
+ import { type LocalProjectLink, readProjectLink, unlinkProject } from "../lib/project-link.ts";
14
15
  import { resolveProject } from "../lib/project-resolver.ts";
15
16
  import { parseWranglerResources } from "../lib/resources.ts";
16
17
  import { deleteCloudProject, getProjectNameFromDir } from "../lib/storage/index.ts";
@@ -124,6 +125,15 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
124
125
  if (!deleteSuccess) {
125
126
  process.exit(0); // User cancelled
126
127
  }
128
+
129
+ // Clean up local tracking state
130
+ const localPath = resolved?.localPath || process.cwd();
131
+ try {
132
+ await unlinkProject(localPath);
133
+ await unregisterPath(projectId, localPath);
134
+ } catch {
135
+ // Non-fatal: local cleanup failed but cloud deletion succeeded
136
+ }
127
137
  return;
128
138
  }
129
139
 
@@ -11,14 +11,27 @@ import { ensureAuth, ensureWrangler, isAuthenticated } from "../lib/wrangler.ts"
11
11
 
12
12
  export async function isInitialized(): Promise<boolean> {
13
13
  const config = await readConfig();
14
- if (!config?.initialized) return false;
15
-
16
- // Check Jack Cloud auth first (most common path)
17
14
  const { isLoggedIn } = await import("../lib/auth/store.ts");
18
- if (await isLoggedIn()) return true;
19
15
 
20
- // Fall back to wrangler/Cloudflare auth (BYO mode)
21
- return await isAuthenticated();
16
+ if (config?.initialized) {
17
+ if ((await isLoggedIn()) || (await isAuthenticated())) return true;
18
+ }
19
+
20
+ // Auto-initialize if authenticated
21
+ const loggedIn = await isLoggedIn();
22
+ const wranglerAuth = !loggedIn && (await isAuthenticated());
23
+
24
+ if (loggedIn || wranglerAuth) {
25
+ await writeConfig({
26
+ version: 1,
27
+ initialized: true,
28
+ initializedAt: new Date().toISOString(),
29
+ ...config,
30
+ });
31
+ return true;
32
+ }
33
+
34
+ return false;
22
35
  }
23
36
 
24
37
  interface InitOptions {
package/src/lib/agents.ts CHANGED
@@ -434,7 +434,9 @@ export interface AgentLaunchContext {
434
434
  function buildInitialPrompt(context: AgentLaunchContext): string | null {
435
435
  if (!context.url) return null;
436
436
 
437
- return `[jack] Project "${context.projectName}" is live at ${context.url} - say hi and let's build!`;
437
+ return `Project "${context.projectName}" is live at ${context.url}
438
+
439
+ Read CLAUDE.md and AGENTS.md for project context, then say hi and offer to help build.`;
438
440
  }
439
441
 
440
442
  function buildLaunchCommand(
@@ -210,9 +210,9 @@ describe("ensureAuthForCreate", () => {
210
210
  });
211
211
 
212
212
  it("throws error when both forceManaged and forceByo are set", async () => {
213
- await expect(
214
- ensureAuthForCreate({ forceManaged: true, forceByo: true }),
215
- ).rejects.toThrow("Cannot use both --managed and --byo flags");
213
+ await expect(ensureAuthForCreate({ forceManaged: true, forceByo: true })).rejects.toThrow(
214
+ "Cannot use both --managed and --byo flags",
215
+ );
216
216
  });
217
217
  });
218
218
 
@@ -2,6 +2,9 @@
2
2
  * Control Plane API client for jack cloud
3
3
  */
4
4
 
5
+ import { debug } from "./debug.ts";
6
+ import { formatSize } from "./format.ts";
7
+
5
8
  const DEFAULT_CONTROL_API_URL = "https://control.getjack.org";
6
9
 
7
10
  export function getControlApiUrl(): string {
@@ -109,6 +112,9 @@ export async function createManagedProject(
109
112
  requestBody.use_prebuilt = options.usePrebuilt;
110
113
  }
111
114
 
115
+ debug("Creating managed project", { name, template: options?.template, usePrebuilt: options?.usePrebuilt });
116
+ const start = Date.now();
117
+
112
118
  const response = await authFetch(`${getControlApiUrl()}/v1/projects`, {
113
119
  method: "POST",
114
120
  headers: {
@@ -118,6 +124,9 @@ export async function createManagedProject(
118
124
  body: JSON.stringify(requestBody),
119
125
  });
120
126
 
127
+ const duration = ((Date.now() - start) / 1000).toFixed(1);
128
+ debug(`Control plane response: ${response.status} (${duration}s)`);
129
+
121
130
  if (!response.ok) {
122
131
  const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
123
132
  message?: string;
@@ -586,10 +595,15 @@ export async function uploadSourceSnapshot(
586
595
  const sourceFile = Bun.file(sourceZipPath);
587
596
  formData.append("source", sourceFile);
588
597
 
589
- const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/source`, {
598
+ const url = `${getControlApiUrl()}/v1/projects/${projectId}/source`;
599
+ debug(`Source snapshot: ${formatSize(sourceFile.size)}`);
600
+
601
+ const start = Date.now();
602
+ const response = await authFetch(url, {
590
603
  method: "POST",
591
604
  body: formData,
592
605
  });
606
+ debug(`Source snapshot: ${response.status} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
593
607
 
594
608
  if (!response.ok) {
595
609
  const error = (await response.json().catch(() => ({ message: "Upload failed" }))) as {
@@ -6,6 +6,8 @@ import { readFile } from "node:fs/promises";
6
6
  import type { AssetManifest } from "./asset-hash.ts";
7
7
  import { authFetch } from "./auth/index.ts";
8
8
  import { getControlApiUrl } from "./control-plane.ts";
9
+ import { debug } from "./debug.ts";
10
+ import { formatSize } from "./format.ts";
9
11
 
10
12
  export interface DeployUploadOptions {
11
13
  projectId: string;
@@ -31,6 +33,9 @@ export interface DeployUploadResult {
31
33
  */
32
34
  export async function uploadDeployment(options: DeployUploadOptions): Promise<DeployUploadResult> {
33
35
  const formData = new FormData();
36
+ let totalSize = 0;
37
+
38
+ const prepareStart = Date.now();
34
39
 
35
40
  // Read files and add to form data
36
41
  const manifestContent = await readFile(options.manifestPath);
@@ -39,17 +44,24 @@ export async function uploadDeployment(options: DeployUploadOptions): Promise<De
39
44
  new Blob([manifestContent], { type: "application/json" }),
40
45
  "manifest.json",
41
46
  );
47
+ totalSize += manifestContent.length;
42
48
 
43
49
  const bundleContent = await readFile(options.bundleZipPath);
44
50
  formData.append("bundle", new Blob([bundleContent], { type: "application/zip" }), "bundle.zip");
51
+ totalSize += bundleContent.length;
52
+ debug(` bundle.zip: ${formatSize(bundleContent.length)}`);
45
53
 
46
54
  const sourceContent = await readFile(options.sourceZipPath);
47
55
  formData.append("source", new Blob([sourceContent], { type: "application/zip" }), "source.zip");
56
+ totalSize += sourceContent.length;
57
+ debug(` source.zip: ${formatSize(sourceContent.length)}`);
48
58
 
49
59
  // Optional files
50
60
  if (options.schemaPath) {
51
61
  const schemaContent = await readFile(options.schemaPath);
52
62
  formData.append("schema", new Blob([schemaContent], { type: "text/sql" }), "schema.sql");
63
+ totalSize += schemaContent.length;
64
+ debug(` schema.sql: ${formatSize(schemaContent.length)}`);
53
65
  }
54
66
 
55
67
  if (options.secretsPath) {
@@ -59,27 +71,40 @@ export async function uploadDeployment(options: DeployUploadOptions): Promise<De
59
71
  new Blob([secretsContent], { type: "application/json" }),
60
72
  "secrets.json",
61
73
  );
74
+ totalSize += secretsContent.length;
62
75
  }
63
76
 
64
77
  if (options.assetsZipPath) {
65
78
  const assetsContent = await readFile(options.assetsZipPath);
66
79
  formData.append("assets", new Blob([assetsContent], { type: "application/zip" }), "assets.zip");
80
+ totalSize += assetsContent.length;
81
+ debug(` assets.zip: ${formatSize(assetsContent.length)}`);
67
82
  }
68
83
 
69
84
  if (options.assetManifest) {
85
+ const manifestJson = JSON.stringify(options.assetManifest);
70
86
  formData.append(
71
87
  "asset-manifest",
72
- new Blob([JSON.stringify(options.assetManifest)], { type: "application/json" }),
88
+ new Blob([manifestJson], { type: "application/json" }),
73
89
  "asset-manifest.json",
74
90
  );
91
+ totalSize += manifestJson.length;
75
92
  }
76
93
 
94
+ const prepareMs = Date.now() - prepareStart;
95
+ debug(`Payload ready: ${formatSize(totalSize)} (${prepareMs}ms)`);
96
+
77
97
  // POST to control plane
78
98
  const url = `${getControlApiUrl()}/v1/projects/${options.projectId}/deployments/upload`;
99
+ debug(`POST ${url}`);
100
+
101
+ const uploadStart = Date.now();
79
102
  const response = await authFetch(url, {
80
103
  method: "POST",
81
104
  body: formData,
82
105
  });
106
+ const uploadMs = Date.now() - uploadStart;
107
+ debug(`Response: ${response.status} (${(uploadMs / 1000).toFixed(1)}s)`);
83
108
 
84
109
  if (!response.ok) {
85
110
  const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
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(
@@ -12,7 +12,7 @@ import { debug } from "./debug.ts";
12
12
  import { uploadDeployment } from "./deploy-upload.ts";
13
13
  import { JackError, JackErrorCode } from "./errors.ts";
14
14
  import { formatSize } from "./format.ts";
15
- import { createUploadProgress } from "./progress.ts";
15
+ import { createFileCountProgress, createUploadProgress } from "./progress.ts";
16
16
  import type { OperationReporter } from "./project-operations.ts";
17
17
  import { getProjectTags } from "./tags.ts";
18
18
  import { Events, track } from "./telemetry.ts";
@@ -116,10 +116,17 @@ export async function deployCodeToManagedProject(
116
116
  );
117
117
  }
118
118
 
119
- // Step 3: Package artifacts
120
- reporter?.start("Packaging artifacts...");
121
- pkg = await packageForDeploy(projectPath, buildOutput, config);
122
- 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();
123
130
  reporter?.success("Packaged artifacts");
124
131
 
125
132
  // Step 4: Upload to control plane
@@ -141,7 +148,7 @@ export async function deployCodeToManagedProject(
141
148
  // Use custom progress with pulsing bar (since fetch doesn't support upload progress)
142
149
  const uploadProgress = createUploadProgress({
143
150
  totalSize: totalUploadSize,
144
- label: "Uploading to jack cloud",
151
+ label: "Deploying to jack cloud",
145
152
  });
146
153
  uploadProgress.start();
147
154
 
@@ -38,6 +38,7 @@ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
38
38
  export function createProgressTracker(options: ProgressOptions) {
39
39
  const { total, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
40
40
  const startTime = Date.now();
41
+ const purple = getRandomPurple(); // Pick color once at start
41
42
  let frame = 0;
42
43
  let intervalId: Timer | null = null;
43
44
  let current = 0;
@@ -55,7 +56,6 @@ export function createProgressTracker(options: ProgressOptions) {
55
56
  );
56
57
  } else {
57
58
  // Show progress bar after delay
58
- const purple = getRandomPurple();
59
59
  const filled = Math.round((pct / 100) * barWidth);
60
60
  const empty = barWidth - filled;
61
61
  const bar = "▓".repeat(filled) + "░".repeat(empty);
@@ -69,7 +69,7 @@ export function createProgressTracker(options: ProgressOptions) {
69
69
  return {
70
70
  start() {
71
71
  render();
72
- intervalId = setInterval(render, 80);
72
+ intervalId = setInterval(render, 150);
73
73
  },
74
74
 
75
75
  update(bytes: number) {
@@ -96,6 +96,7 @@ export function createProgressTracker(options: ProgressOptions) {
96
96
  export function createUploadProgress(options: UploadProgressOptions) {
97
97
  const { totalSize, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
98
98
  const startTime = Date.now();
99
+ const purple = getRandomPurple(); // Pick color once at start
99
100
  let frame = 0;
100
101
  let pulsePos = 0;
101
102
  let intervalId: Timer | null = null;
@@ -113,8 +114,6 @@ export function createUploadProgress(options: UploadProgressOptions) {
113
114
  );
114
115
  } else {
115
116
  // Show pulsing bar after delay (indicates activity without false progress)
116
- const purple = getRandomPurple();
117
-
118
117
  // Create pulsing effect - a bright section that moves across the bar
119
118
  const pulseWidth = 5;
120
119
  pulsePos = (pulsePos + 1) % (barWidth + pulseWidth);
@@ -139,10 +138,82 @@ export function createUploadProgress(options: UploadProgressOptions) {
139
138
  start() {
140
139
  if (process.stderr.isTTY) {
141
140
  render();
142
- intervalId = setInterval(render, 80);
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);
143
209
  }
144
210
  },
145
211
 
212
+ update(currentFile: number, totalFiles: number) {
213
+ current = currentFile;
214
+ total = totalFiles;
215
+ },
216
+
146
217
  complete() {
147
218
  if (intervalId) {
148
219
  clearInterval(intervalId);
@@ -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