@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.
- package/package.json +1 -1
- package/src/commands/down.ts +11 -1
- package/src/commands/init.ts +19 -6
- package/src/lib/agents.ts +3 -1
- package/src/lib/auth/ensure-auth.test.ts +3 -3
- package/src/lib/control-plane.ts +15 -1
- package/src/lib/deploy-upload.ts +26 -1
- package/src/lib/hooks.ts +232 -1
- package/src/lib/managed-deploy.ts +13 -6
- package/src/lib/progress.ts +76 -5
- package/src/lib/project-list.ts +6 -1
- package/src/lib/project-operations.ts +14 -0
- package/src/lib/project-resolver.ts +1 -1
- package/src/lib/zip-packager.ts +36 -7
- 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/package.json
CHANGED
package/src/commands/down.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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 `
|
|
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
|
-
|
|
215
|
-
)
|
|
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
|
|
package/src/lib/control-plane.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/lib/deploy-upload.ts
CHANGED
|
@@ -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([
|
|
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?.
|
|
121
|
-
|
|
122
|
-
|
|
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: "
|
|
151
|
+
label: "Deploying to jack cloud",
|
|
145
152
|
});
|
|
146
153
|
uploadProgress.start();
|
|
147
154
|
|
package/src/lib/progress.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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);
|
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
|
|