@getjack/jack 0.1.17 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +2 -5
  2. package/src/commands/clone.ts +62 -40
  3. package/src/commands/down.ts +11 -1
  4. package/src/commands/init.ts +21 -2
  5. package/src/commands/services.ts +85 -4
  6. package/src/commands/sync.ts +9 -0
  7. package/src/commands/update.ts +10 -0
  8. package/src/lib/agents.ts +3 -1
  9. package/src/lib/auth/ensure-auth.test.ts +3 -3
  10. package/src/lib/control-plane.ts +77 -1
  11. package/src/lib/deploy-upload.ts +26 -1
  12. package/src/lib/hooks.ts +232 -1
  13. package/src/lib/managed-deploy.ts +37 -6
  14. package/src/lib/output.ts +21 -3
  15. package/src/lib/progress.ts +231 -0
  16. package/src/lib/project-list.ts +6 -1
  17. package/src/lib/project-operations.ts +117 -62
  18. package/src/lib/project-resolver.ts +1 -1
  19. package/src/lib/services/db-create.ts +6 -3
  20. package/src/lib/version-check.ts +14 -0
  21. package/src/lib/wrangler-config.ts +190 -0
  22. package/src/lib/zip-packager.ts +74 -7
  23. package/src/lib/zip-utils.ts +38 -0
  24. package/src/templates/index.ts +1 -1
  25. package/src/templates/types.ts +16 -0
  26. package/templates/CLAUDE.md +103 -0
  27. package/templates/miniapp/.jack.json +1 -3
  28. package/templates/saas/.jack.json +154 -0
  29. package/templates/saas/AGENTS.md +333 -0
  30. package/templates/saas/bun.lock +925 -0
  31. package/templates/saas/components.json +21 -0
  32. package/templates/saas/index.html +12 -0
  33. package/templates/saas/package.json +75 -0
  34. package/templates/saas/public/icon.png +0 -0
  35. package/templates/saas/public/og.png +0 -0
  36. package/templates/saas/schema.sql +73 -0
  37. package/templates/saas/src/auth.ts +77 -0
  38. package/templates/saas/src/client/App.tsx +63 -0
  39. package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
  40. package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
  41. package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
  42. package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
  43. package/templates/saas/src/client/components/ui/alert.tsx +60 -0
  44. package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
  45. package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
  46. package/templates/saas/src/client/components/ui/badge.tsx +39 -0
  47. package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
  48. package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
  49. package/templates/saas/src/client/components/ui/button.tsx +60 -0
  50. package/templates/saas/src/client/components/ui/card.tsx +75 -0
  51. package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
  52. package/templates/saas/src/client/components/ui/chart.tsx +326 -0
  53. package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
  54. package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
  55. package/templates/saas/src/client/components/ui/command.tsx +159 -0
  56. package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
  57. package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
  58. package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
  59. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
  60. package/templates/saas/src/client/components/ui/empty.tsx +94 -0
  61. package/templates/saas/src/client/components/ui/field.tsx +232 -0
  62. package/templates/saas/src/client/components/ui/form.tsx +152 -0
  63. package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
  64. package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
  65. package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
  66. package/templates/saas/src/client/components/ui/input.tsx +21 -0
  67. package/templates/saas/src/client/components/ui/item.tsx +172 -0
  68. package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
  69. package/templates/saas/src/client/components/ui/label.tsx +21 -0
  70. package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
  71. package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
  72. package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
  73. package/templates/saas/src/client/components/ui/popover.tsx +42 -0
  74. package/templates/saas/src/client/components/ui/progress.tsx +26 -0
  75. package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
  76. package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
  77. package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
  78. package/templates/saas/src/client/components/ui/select.tsx +173 -0
  79. package/templates/saas/src/client/components/ui/separator.tsx +28 -0
  80. package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
  81. package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
  82. package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
  83. package/templates/saas/src/client/components/ui/slider.tsx +58 -0
  84. package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
  85. package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
  86. package/templates/saas/src/client/components/ui/switch.tsx +28 -0
  87. package/templates/saas/src/client/components/ui/table.tsx +90 -0
  88. package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
  89. package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
  90. package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
  91. package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
  92. package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
  93. package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
  94. package/templates/saas/src/client/hooks/useAuth.ts +14 -0
  95. package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
  96. package/templates/saas/src/client/index.css +165 -0
  97. package/templates/saas/src/client/lib/auth-client.ts +7 -0
  98. package/templates/saas/src/client/lib/plans.ts +82 -0
  99. package/templates/saas/src/client/lib/utils.ts +6 -0
  100. package/templates/saas/src/client/main.tsx +15 -0
  101. package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
  102. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
  103. package/templates/saas/src/client/pages/HomePage.tsx +285 -0
  104. package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
  105. package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
  106. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
  107. package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
  108. package/templates/saas/src/index.ts +208 -0
  109. package/templates/saas/tsconfig.json +18 -0
  110. package/templates/saas/vite.config.ts +14 -0
  111. package/templates/saas/wrangler.jsonc +20 -0
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.17",
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",
7
7
  "bin": {
8
8
  "jack": "./src/index.ts"
9
9
  },
10
- "files": [
11
- "src",
12
- "templates"
13
- ],
10
+ "files": ["src", "templates"],
14
11
  "engines": {
15
12
  "bun": ">=1.0.0"
16
13
  },
@@ -1,13 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { isCancel, select } from "@clack/prompts";
4
- import { fetchProjectTags } from "../lib/control-plane.ts";
4
+ import { downloadProjectSource, fetchProjectTags } from "../lib/control-plane.ts";
5
5
  import { formatSize } from "../lib/format.ts";
6
6
  import { box, error, info, spinner, success } from "../lib/output.ts";
7
7
  import { registerPath } from "../lib/paths-index.ts";
8
8
  import { linkProject, updateProjectLink } from "../lib/project-link.ts";
9
9
  import { resolveProject } from "../lib/project-resolver.ts";
10
10
  import { cloneFromCloud, getRemoteManifest } from "../lib/storage/index.ts";
11
+ import { extractZipToDirectory } from "../lib/zip-utils.ts";
11
12
 
12
13
  export interface CloneFlags {
13
14
  as?: string;
@@ -53,53 +54,74 @@ export default async function clone(projectName?: string, flags: CloneFlags = {}
53
54
  }
54
55
  }
55
56
 
56
- // Fetch remote manifest
57
- const spin = spinner(`Fetching from jack-storage/${projectName}/...`);
58
- const manifest = await getRemoteManifest(projectName);
57
+ // Check if this is a managed project (has source in control-plane)
58
+ const spin = spinner(`Looking up ${projectName}...`);
59
+ let project: Awaited<ReturnType<typeof resolveProject>> = null;
59
60
 
60
- if (!manifest) {
61
- spin.error(`Project not found: ${projectName}`);
62
- process.exit(1);
61
+ try {
62
+ project = await resolveProject(projectName);
63
+ } catch {
64
+ // Not found on control-plane, will fall back to User R2
63
65
  }
64
66
 
65
- // Show file count and size
66
- const totalSize = manifest.files.reduce((sum, f) => sum + f.size, 0);
67
- spin.success(`Found ${manifest.files.length} file(s) (${formatSize(totalSize)})`);
67
+ // Managed mode: download from control-plane
68
+ if (project?.sources.controlPlane && project.remote?.projectId) {
69
+ spin.success("Found on jack cloud");
70
+
71
+ const downloadSpin = spinner("Downloading from jack cloud...");
72
+ try {
73
+ const sourceZip = await downloadProjectSource(projectName);
74
+ const fileCount = await extractZipToDirectory(sourceZip, targetDir);
75
+ downloadSpin.success(`Restored ${fileCount} file(s) to ./${flags.as ?? projectName}/`);
76
+ } catch (err) {
77
+ downloadSpin.error("Download failed");
78
+ const message = err instanceof Error ? err.message : "Could not download project source";
79
+ error(message);
80
+ process.exit(1);
81
+ }
68
82
 
69
- // Download files
70
- const downloadSpin = spinner("Downloading...");
71
- const result = await cloneFromCloud(projectName, targetDir);
83
+ // Link to control-plane
84
+ await linkProject(targetDir, project.remote.projectId, "managed");
85
+ await registerPath(project.remote.projectId, targetDir);
72
86
 
73
- if (!result.success) {
74
- downloadSpin.error("Clone failed");
75
- error(result.error || "Could not download project files");
76
- info("Check your network connection and try again");
77
- process.exit(1);
78
- }
87
+ // Fetch and restore tags from control plane
88
+ try {
89
+ const remoteTags = await fetchProjectTags(project.remote.projectId);
90
+ if (remoteTags.length > 0) {
91
+ await updateProjectLink(targetDir, { tags: remoteTags });
92
+ info(`Restored ${remoteTags.length} tag(s)`);
93
+ }
94
+ } catch {
95
+ // Silent fail - tag restoration is non-critical
96
+ }
97
+ } else {
98
+ // BYO mode: use existing User R2 flow
99
+ spin.stop();
100
+ const fetchSpin = spinner(`Fetching from jack-storage/${projectName}/...`);
101
+ const manifest = await getRemoteManifest(projectName);
102
+
103
+ if (!manifest) {
104
+ fetchSpin.error(`Project not found: ${projectName}`);
105
+ info("For BYO projects, run 'jack sync' first to backup your project.");
106
+ process.exit(1);
107
+ }
79
108
 
80
- downloadSpin.success(`Restored to ./${flags.as ?? projectName}/`);
109
+ // Show file count and size
110
+ const totalSize = manifest.files.reduce((sum, f) => sum + f.size, 0);
111
+ fetchSpin.success(`Found ${manifest.files.length} file(s) (${formatSize(totalSize)})`);
81
112
 
82
- // Link project to control plane if it's a managed project
83
- try {
84
- const project = await resolveProject(projectName);
85
- if (project?.sources.controlPlane && project.remote?.projectId) {
86
- // Managed project - link with control plane project ID
87
- await linkProject(targetDir, project.remote.projectId, "managed");
88
- await registerPath(project.remote.projectId, targetDir);
89
-
90
- // Fetch and restore tags from control plane
91
- try {
92
- const remoteTags = await fetchProjectTags(project.remote.projectId);
93
- if (remoteTags.length > 0) {
94
- await updateProjectLink(targetDir, { tags: remoteTags });
95
- info(`Restored ${remoteTags.length} tag(s)`);
96
- }
97
- } catch {
98
- // Silent fail - tag restoration is non-critical
99
- }
113
+ // Download files
114
+ const downloadSpin = spinner("Downloading...");
115
+ const result = await cloneFromCloud(projectName, targetDir);
116
+
117
+ if (!result.success) {
118
+ downloadSpin.error("Clone failed");
119
+ error(result.error || "Could not download project files");
120
+ info("Check your network connection and try again");
121
+ process.exit(1);
100
122
  }
101
- } catch {
102
- // Not a control plane project or offline - continue without linking
123
+
124
+ downloadSpin.success(`Restored to ./${flags.as ?? projectName}/`);
103
125
  }
104
126
 
105
127
  // Show next steps
@@ -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,8 +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
- return await isAuthenticated();
14
+ const { isLoggedIn } = await import("../lib/auth/store.ts");
15
+
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;
16
35
  }
17
36
 
18
37
  interface InitOptions {
@@ -277,6 +277,12 @@ async function dbExport(options: ServiceOptions): Promise<void> {
277
277
  */
278
278
  async function dbDelete(options: ServiceOptions): Promise<void> {
279
279
  const projectName = await resolveProjectName(options);
280
+ const projectDir = process.cwd();
281
+
282
+ // Check deploy mode
283
+ const link = await readProjectLink(projectDir);
284
+ const isManaged = link?.deploy_mode === "managed";
285
+
280
286
  const dbInfo = await resolveDatabaseInfo(projectName);
281
287
 
282
288
  if (!dbInfo) {
@@ -314,14 +320,80 @@ async function dbDelete(options: ServiceOptions): Promise<void> {
314
320
  return;
315
321
  }
316
322
 
317
- // Delete database
318
323
  outputSpinner.start("Deleting database...");
324
+
325
+ // Track binding_name from control plane for matching in wrangler.jsonc
326
+ let controlPlaneBindingName: string | null = null;
327
+
319
328
  try {
320
- await deleteDatabase(dbInfo.name);
329
+ if (isManaged && link) {
330
+ // Managed mode: delete via control plane
331
+ const { fetchProjectResources, deleteProjectResource } = await import(
332
+ "../lib/control-plane.ts"
333
+ );
334
+
335
+ // Find the resource ID for this database
336
+ const resources = await fetchProjectResources(link.project_id);
337
+ const d1Resource = resources.find(
338
+ (r) => r.resource_type === "d1" && r.resource_name === dbInfo.name,
339
+ );
340
+
341
+ if (d1Resource) {
342
+ // Save binding_name for wrangler.jsonc cleanup
343
+ controlPlaneBindingName = d1Resource.binding_name;
344
+ // Delete via control plane (which also deletes from Cloudflare)
345
+ await deleteProjectResource(link.project_id, d1Resource.id);
346
+ } else {
347
+ // Resource not in control plane - fall back to wrangler for cleanup
348
+ await deleteDatabase(dbInfo.name);
349
+ }
350
+ } else {
351
+ // BYO mode: delete via wrangler directly
352
+ await deleteDatabase(dbInfo.name);
353
+ }
354
+
355
+ // Remove binding from wrangler.jsonc (both modes)
356
+ // Note: We need to find the LOCAL database_name from wrangler.jsonc,
357
+ // which may differ from the control plane's resource_name
358
+ const { removeD1Binding, getExistingD1Bindings } = await import("../lib/wrangler-config.ts");
359
+ const configPath = join(projectDir, "wrangler.jsonc");
360
+
361
+ let bindingRemoved = false;
362
+ try {
363
+ // Find the binding by matching (in order of reliability):
364
+ // 1. binding name (e.g., "DB") - if control plane provided it
365
+ // 2. database_id (provider_id from control plane)
366
+ // 3. database_name
367
+ // 4. If managed mode and we successfully deleted, remove first D1 binding
368
+ const existingBindings = await getExistingD1Bindings(configPath);
369
+ let bindingToRemove = existingBindings.find(
370
+ (b) =>
371
+ (controlPlaneBindingName && b.binding === controlPlaneBindingName) ||
372
+ b.database_id === dbInfo.id ||
373
+ b.database_name === dbInfo.name,
374
+ );
375
+
376
+ // Fallback: if managed mode and we deleted from control plane,
377
+ // remove the first D1 binding (binding_name may be null for older DBs)
378
+ if (!bindingToRemove && isManaged && existingBindings.length > 0) {
379
+ bindingToRemove = existingBindings[0];
380
+ }
381
+
382
+ if (bindingToRemove) {
383
+ bindingRemoved = await removeD1Binding(configPath, bindingToRemove.database_name);
384
+ }
385
+ } catch (bindingErr) {
386
+ // Log but don't fail - the database is already deleted
387
+ // The user can manually clean up wrangler.jsonc if needed
388
+ }
389
+
321
390
  outputSpinner.stop();
322
391
 
323
392
  console.error("");
324
393
  success("Database deleted");
394
+ if (bindingRemoved) {
395
+ item("Binding removed from wrangler.jsonc");
396
+ }
325
397
  console.error("");
326
398
  } catch (err) {
327
399
  outputSpinner.stop();
@@ -332,10 +404,11 @@ async function dbDelete(options: ServiceOptions): Promise<void> {
332
404
  }
333
405
 
334
406
  /**
335
- * Parse --name flag from args
336
- * Supports: --name foo, --name=foo
407
+ * Parse --name flag or positional arg from args
408
+ * Supports: --name foo, --name=foo, or first positional arg
337
409
  */
338
410
  function parseNameFlag(args: string[]): string | undefined {
411
+ // Check --name flag first (takes priority)
339
412
  for (let i = 0; i < args.length; i++) {
340
413
  const arg = args[i];
341
414
  if (arg === "--name" && args[i + 1]) {
@@ -345,6 +418,14 @@ function parseNameFlag(args: string[]): string | undefined {
345
418
  return arg.slice("--name=".length);
346
419
  }
347
420
  }
421
+
422
+ // Fall back to first positional argument (non-flag)
423
+ for (const arg of args) {
424
+ if (!arg.startsWith("-")) {
425
+ return arg;
426
+ }
427
+ }
428
+
348
429
  return undefined;
349
430
  }
350
431
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { getSyncConfig } from "../lib/config.ts";
3
3
  import { error, info, spinner, success, warn } from "../lib/output.ts";
4
+ import { readProjectLink } from "../lib/project-link.ts";
4
5
  import { syncToCloud } from "../lib/storage/index.ts";
5
6
 
6
7
  export interface SyncFlags {
@@ -25,6 +26,14 @@ export default async function sync(flags: SyncFlags = {}): Promise<void> {
25
26
  process.exit(1);
26
27
  }
27
28
 
29
+ // Check if this is a managed project
30
+ const link = await readProjectLink(process.cwd());
31
+ if (link?.deploy_mode === "managed") {
32
+ info("Managed projects are automatically backed up to jack cloud during deploy.");
33
+ info("Use 'jack clone <project>' on another machine to restore.");
34
+ return;
35
+ }
36
+
28
37
  // Check if sync is enabled
29
38
  const syncConfig = await getSyncConfig();
30
39
  if (!syncConfig.enabled) {
@@ -2,6 +2,7 @@
2
2
  * jack update - Self-update to the latest version
3
3
  */
4
4
 
5
+ import { debug } from "../lib/debug.ts";
5
6
  import { error, info, success, warn } from "../lib/output.ts";
6
7
  import {
7
8
  checkForUpdate,
@@ -13,8 +14,13 @@ import {
13
14
  export default async function update(): Promise<void> {
14
15
  const currentVersion = getCurrentVersion();
15
16
 
17
+ debug("Update check started");
18
+ debug(`Current version: ${currentVersion}`);
19
+ debug(`Exec path: ${process.argv[1]}`);
20
+
16
21
  // Check if running via bunx
17
22
  if (isRunningViaBunx()) {
23
+ debug("Detected bunx execution");
18
24
  info(`Running via bunx (current: v${currentVersion})`);
19
25
  info("bunx automatically uses cached packages.");
20
26
  info("To get the latest version, run:");
@@ -28,7 +34,9 @@ export default async function update(): Promise<void> {
28
34
  info(`Current version: v${currentVersion}`);
29
35
 
30
36
  // Check for updates
37
+ debug("Fetching latest version from npm...");
31
38
  const latestVersion = await checkForUpdate();
39
+ debug(`Latest version from npm: ${latestVersion ?? "none (you're up to date)"}`);
32
40
 
33
41
  if (!latestVersion) {
34
42
  success("You're on the latest version!");
@@ -38,7 +46,9 @@ export default async function update(): Promise<void> {
38
46
  info(`New version available: v${latestVersion}`);
39
47
  info("Updating...");
40
48
 
49
+ debug("Running: bun add -g @getjack/jack@latest");
41
50
  const result = await performUpdate();
51
+ debug(`Update result: ${JSON.stringify(result)}`);
42
52
 
43
53
  if (result.success) {
44
54
  success(`Updated to v${result.version ?? latestVersion}`);
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;
@@ -371,6 +380,46 @@ export async function createProjectResource(
371
380
  return data.resource;
372
381
  }
373
382
 
383
+ export interface DeleteResourceResponse {
384
+ success: boolean;
385
+ resource_id: string;
386
+ deleted_at: string;
387
+ }
388
+
389
+ /**
390
+ * Delete a resource from a managed project.
391
+ * Uses DELETE /v1/projects/:id/resources/:id endpoint.
392
+ */
393
+ export async function deleteProjectResource(
394
+ projectId: string,
395
+ resourceId: string,
396
+ ): Promise<DeleteResourceResponse> {
397
+ const { authFetch } = await import("./auth/index.ts");
398
+
399
+ const response = await authFetch(
400
+ `${getControlApiUrl()}/v1/projects/${projectId}/resources/${resourceId}`,
401
+ { method: "DELETE" },
402
+ );
403
+
404
+ // Handle 404 gracefully - resource may already be deleted
405
+ if (response.status === 404) {
406
+ return {
407
+ success: true,
408
+ resource_id: resourceId,
409
+ deleted_at: new Date().toISOString(),
410
+ };
411
+ }
412
+
413
+ if (!response.ok) {
414
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
415
+ message?: string;
416
+ };
417
+ throw new Error(err.message || `Failed to delete resource: ${response.status}`);
418
+ }
419
+
420
+ return response.json() as Promise<DeleteResourceResponse>;
421
+ }
422
+
374
423
  /**
375
424
  * Sync project tags to the control plane.
376
425
  * Fire-and-forget: errors are logged but not thrown.
@@ -546,10 +595,15 @@ export async function uploadSourceSnapshot(
546
595
  const sourceFile = Bun.file(sourceZipPath);
547
596
  formData.append("source", sourceFile);
548
597
 
549
- 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, {
550
603
  method: "POST",
551
604
  body: formData,
552
605
  });
606
+ debug(`Source snapshot: ${response.status} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
553
607
 
554
608
  if (!response.ok) {
555
609
  const error = (await response.json().catch(() => ({ message: "Upload failed" }))) as {
@@ -580,3 +634,25 @@ export async function publishProject(projectId: string): Promise<PublishProjectR
580
634
 
581
635
  return response.json() as Promise<PublishProjectResponse>;
582
636
  }
637
+
638
+ /**
639
+ * Download the source snapshot for a project.
640
+ * Returns the zip file contents as a Buffer.
641
+ * Used by jack clone to restore managed projects.
642
+ */
643
+ export async function downloadProjectSource(slug: string): Promise<Buffer> {
644
+ const { authFetch } = await import("./auth/index.ts");
645
+
646
+ const response = await authFetch(
647
+ `${getControlApiUrl()}/v1/projects/by-slug/${encodeURIComponent(slug)}/source`,
648
+ );
649
+
650
+ if (!response.ok) {
651
+ if (response.status === 404) {
652
+ throw new Error("Project source not found. Deploy first with 'jack ship'.");
653
+ }
654
+ throw new Error(`Failed to download source: ${response.status}`);
655
+ }
656
+
657
+ return Buffer.from(await response.arrayBuffer());
658
+ }
@@ -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 {