@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.
- package/package.json +2 -5
- package/src/commands/clone.ts +62 -40
- package/src/commands/down.ts +11 -1
- package/src/commands/init.ts +21 -2
- package/src/commands/services.ts +85 -4
- package/src/commands/sync.ts +9 -0
- package/src/commands/update.ts +10 -0
- package/src/lib/agents.ts +3 -1
- package/src/lib/auth/ensure-auth.test.ts +3 -3
- package/src/lib/control-plane.ts +77 -1
- package/src/lib/deploy-upload.ts +26 -1
- package/src/lib/hooks.ts +232 -1
- package/src/lib/managed-deploy.ts +37 -6
- package/src/lib/output.ts +21 -3
- package/src/lib/progress.ts +231 -0
- package/src/lib/project-list.ts +6 -1
- package/src/lib/project-operations.ts +117 -62
- package/src/lib/project-resolver.ts +1 -1
- package/src/lib/services/db-create.ts +6 -3
- package/src/lib/version-check.ts +14 -0
- package/src/lib/wrangler-config.ts +190 -0
- package/src/lib/zip-packager.ts +74 -7
- package/src/lib/zip-utils.ts +38 -0
- package/src/templates/index.ts +1 -1
- package/src/templates/types.ts +16 -0
- package/templates/CLAUDE.md +103 -0
- package/templates/miniapp/.jack.json +1 -3
- package/templates/saas/.jack.json +154 -0
- package/templates/saas/AGENTS.md +333 -0
- package/templates/saas/bun.lock +925 -0
- package/templates/saas/components.json +21 -0
- package/templates/saas/index.html +12 -0
- package/templates/saas/package.json +75 -0
- package/templates/saas/public/icon.png +0 -0
- package/templates/saas/public/og.png +0 -0
- package/templates/saas/schema.sql +73 -0
- package/templates/saas/src/auth.ts +77 -0
- package/templates/saas/src/client/App.tsx +63 -0
- package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
- package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
- package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
- package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
- package/templates/saas/src/client/components/ui/alert.tsx +60 -0
- package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
- package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
- package/templates/saas/src/client/components/ui/badge.tsx +39 -0
- package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
- package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
- package/templates/saas/src/client/components/ui/button.tsx +60 -0
- package/templates/saas/src/client/components/ui/card.tsx +75 -0
- package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
- package/templates/saas/src/client/components/ui/chart.tsx +326 -0
- package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
- package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
- package/templates/saas/src/client/components/ui/command.tsx +159 -0
- package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
- package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
- package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
- package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
- package/templates/saas/src/client/components/ui/empty.tsx +94 -0
- package/templates/saas/src/client/components/ui/field.tsx +232 -0
- package/templates/saas/src/client/components/ui/form.tsx +152 -0
- package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
- package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
- package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
- package/templates/saas/src/client/components/ui/input.tsx +21 -0
- package/templates/saas/src/client/components/ui/item.tsx +172 -0
- package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
- package/templates/saas/src/client/components/ui/label.tsx +21 -0
- package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
- package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
- package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
- package/templates/saas/src/client/components/ui/popover.tsx +42 -0
- package/templates/saas/src/client/components/ui/progress.tsx +26 -0
- package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
- package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
- package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
- package/templates/saas/src/client/components/ui/select.tsx +173 -0
- package/templates/saas/src/client/components/ui/separator.tsx +28 -0
- package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
- package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
- package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
- package/templates/saas/src/client/components/ui/slider.tsx +58 -0
- package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
- package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
- package/templates/saas/src/client/components/ui/switch.tsx +28 -0
- package/templates/saas/src/client/components/ui/table.tsx +90 -0
- package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
- package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
- package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
- package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
- package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
- package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
- package/templates/saas/src/client/hooks/useAuth.ts +14 -0
- package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
- package/templates/saas/src/client/index.css +165 -0
- package/templates/saas/src/client/lib/auth-client.ts +7 -0
- package/templates/saas/src/client/lib/plans.ts +82 -0
- package/templates/saas/src/client/lib/utils.ts +6 -0
- package/templates/saas/src/client/main.tsx +15 -0
- package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
- package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
- package/templates/saas/src/client/pages/HomePage.tsx +285 -0
- package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
- package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
- package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
- package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
- package/templates/saas/src/index.ts +208 -0
- package/templates/saas/tsconfig.json +18 -0
- package/templates/saas/vite.config.ts +14 -0
- 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?.
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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}║${
|
|
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
|
+
}
|
package/src/lib/project-list.ts
CHANGED
|
@@ -338,9 +338,14 @@ export function formatErrorSection(
|
|
|
338
338
|
);
|
|
339
339
|
|
|
340
340
|
for (const item of items) {
|
|
341
|
-
|
|
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
|
|