@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
@@ -39,8 +39,7 @@ import {
39
39
  slugify,
40
40
  writeWranglerConfig,
41
41
  } from "./config-generator.ts";
42
- import { getSyncConfig } from "./config.ts";
43
- import { deleteManagedProject } from "./control-plane.ts";
42
+ import { deleteManagedProject, listManagedProjects } from "./control-plane.ts";
44
43
  import { debug, isDebug, printTimingSummary, timerEnd, timerStart } from "./debug.ts";
45
44
  import { ensureWranglerInstalled, validateModeAvailability } from "./deploy-mode.ts";
46
45
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
@@ -67,7 +66,7 @@ import {
67
66
  import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
68
67
  import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./schema.ts";
69
68
  import { getSavedSecrets, saveSecrets } from "./secrets.ts";
70
- import { getProjectNameFromDir, getRemoteManifest, syncToCloud } from "./storage/index.ts";
69
+ import { getProjectNameFromDir, getRemoteManifest } from "./storage/index.ts";
71
70
  import { Events, track } from "./telemetry.ts";
72
71
 
73
72
  // ============================================================================
@@ -364,7 +363,11 @@ async function runParallelSetup(
364
363
  installSuccess: boolean;
365
364
  remoteResult: ManagedCreateResult;
366
365
  }> {
366
+ const setupStart = Date.now();
367
+ debug("Parallel setup started", { template: options.template, usePrebuilt: options.usePrebuilt });
368
+
367
369
  // Start both operations
370
+ const installStart = Date.now();
368
371
  const installPromise = (async () => {
369
372
  const install = Bun.spawn(["bun", "install", "--prefer-offline"], {
370
373
  cwd: targetDir,
@@ -372,15 +375,22 @@ async function runParallelSetup(
372
375
  stderr: "ignore",
373
376
  });
374
377
  await install.exited;
378
+ const duration = ((Date.now() - installStart) / 1000).toFixed(1);
379
+ debug(`bun install completed in ${duration}s (exit: ${install.exitCode})`);
375
380
  if (install.exitCode !== 0) {
376
381
  throw new Error("Dependency installation failed");
377
382
  }
378
383
  return true;
379
384
  })();
380
385
 
386
+ const remoteStart = Date.now();
381
387
  const remotePromise = createManagedProjectRemote(projectName, undefined, {
382
388
  template: options.template || "hello",
383
389
  usePrebuilt: options.usePrebuilt ?? true,
390
+ }).then((result) => {
391
+ const duration = ((Date.now() - remoteStart) / 1000).toFixed(1);
392
+ debug(`Remote project created in ${duration}s (status: ${result.status})`);
393
+ return result;
384
394
  });
385
395
 
386
396
  // Report URL as soon as remote is ready (don't wait for install)
@@ -435,6 +445,9 @@ async function runParallelSetup(
435
445
  throw new Error("Unexpected state: remote result not fulfilled");
436
446
  }
437
447
 
448
+ const totalDuration = ((Date.now() - setupStart) / 1000).toFixed(1);
449
+ debug(`Parallel setup completed in ${totalDuration}s`);
450
+
438
451
  return {
439
452
  installSuccess: true,
440
453
  remoteResult: remoteResult.value,
@@ -973,6 +986,25 @@ export async function createProject(
973
986
  const rendered = renderTemplate(template, { name: projectName });
974
987
  timings.push({ label: "Template load", duration: timerEnd("template-load") });
975
988
 
989
+ // Run preCreate hooks (for interactive secret collection, auto-generation, etc.)
990
+ if (template.hooks?.preCreate?.length) {
991
+ timerStart("pre-create-hooks");
992
+ const hookContext = { projectName, projectDir: targetDir };
993
+ const hookResult = await runHook(template.hooks.preCreate, hookContext, {
994
+ interactive,
995
+ output: reporter,
996
+ });
997
+ timings.push({ label: "Pre-create hooks", duration: timerEnd("pre-create-hooks") });
998
+
999
+ if (!hookResult.success) {
1000
+ throw new JackError(
1001
+ JackErrorCode.VALIDATION_ERROR,
1002
+ "Project setup incomplete",
1003
+ "Missing required configuration",
1004
+ );
1005
+ }
1006
+ }
1007
+
976
1008
  // Handle template-specific secrets
977
1009
  const secretsToUse: Record<string, string> = {};
978
1010
  if (template.secrets?.length) {
@@ -1018,41 +1050,34 @@ export async function createProject(
1018
1050
  continue;
1019
1051
  }
1020
1052
 
1021
- // Prompt user
1053
+ // Prompt user - single text input, empty/Esc to skip
1022
1054
  reporter.stop();
1023
- const { isCancel, select, text } = await import("@clack/prompts");
1055
+ const { isCancel, text } = await import("@clack/prompts");
1024
1056
  console.error("");
1025
1057
  console.error(` ${optionalSecret.description}`);
1026
1058
  if (optionalSecret.setupUrl) {
1027
- console.error(` Setup: ${optionalSecret.setupUrl}`);
1059
+ console.error(` Get it at: \x1b[36m${optionalSecret.setupUrl}\x1b[0m`);
1028
1060
  }
1029
1061
  console.error("");
1030
1062
 
1031
- const choice = await select({
1032
- message: `Add ${optionalSecret.name}?`,
1033
- options: [
1034
- { label: "Yes", value: "yes" },
1035
- { label: "Skip", value: "skip" },
1036
- ],
1063
+ const value = await text({
1064
+ message: `${optionalSecret.name}:`,
1065
+ placeholder: "paste value or press Esc to skip",
1037
1066
  });
1038
1067
 
1039
- if (!isCancel(choice) && choice === "yes") {
1040
- const value = await text({
1041
- message: `Enter ${optionalSecret.name}:`,
1042
- });
1043
-
1044
- if (!isCancel(value) && value.trim()) {
1045
- secretsToUse[optionalSecret.name] = value.trim();
1046
- // Save to global secrets for reuse
1047
- await saveSecrets([
1048
- {
1049
- key: optionalSecret.name,
1050
- value: value.trim(),
1051
- source: "optional-template",
1052
- },
1053
- ]);
1054
- reporter.success(`Saved ${optionalSecret.name}`);
1055
- }
1068
+ if (!isCancel(value) && value.trim()) {
1069
+ secretsToUse[optionalSecret.name] = value.trim();
1070
+ // Save to global secrets for reuse
1071
+ await saveSecrets([
1072
+ {
1073
+ key: optionalSecret.name,
1074
+ value: value.trim(),
1075
+ source: "optional-template",
1076
+ },
1077
+ ]);
1078
+ reporter.success(`Saved ${optionalSecret.name}`);
1079
+ } else {
1080
+ reporter.info(`Skipped ${optionalSecret.name}`);
1056
1081
  }
1057
1082
 
1058
1083
  reporter.start("Creating project...");
@@ -1309,6 +1334,23 @@ export async function createProject(
1309
1334
  if (!urlShownEarly) {
1310
1335
  reporter.success(`Deployed: ${workerUrl}`);
1311
1336
  }
1337
+
1338
+ // Upload source snapshot for forking (prebuilt path needs this too)
1339
+ try {
1340
+ const { createSourceZip } = await import("./zip-packager.ts");
1341
+ const { uploadSourceSnapshot } = await import("./control-plane.ts");
1342
+ const { rm } = await import("node:fs/promises");
1343
+
1344
+ const sourceZipPath = await createSourceZip(targetDir);
1345
+ await uploadSourceSnapshot(remoteResult.projectId, sourceZipPath);
1346
+ await rm(sourceZipPath, { force: true });
1347
+ debug("Source snapshot uploaded for prebuilt project");
1348
+ } catch (err) {
1349
+ debug(
1350
+ "Source snapshot upload failed (prebuilt):",
1351
+ err instanceof Error ? err.message : String(err),
1352
+ );
1353
+ }
1312
1354
  } else {
1313
1355
  // Prebuilt not available - fall back to fresh build
1314
1356
  if (remoteResult.prebuiltFailed) {
@@ -1769,28 +1811,9 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1769
1811
  }
1770
1812
  }
1771
1813
 
1772
- if (includeSync && deployMode !== "byo") {
1773
- const syncConfig = await getSyncConfig();
1774
- if (syncConfig.enabled && syncConfig.autoSync) {
1775
- const syncSpin = reporter.spinner("Syncing source to jack storage...");
1776
- try {
1777
- const syncResult = await syncToCloud(projectPath);
1778
- if (syncResult.success) {
1779
- if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
1780
- syncSpin.success(
1781
- `Synced source to jack storage (${syncResult.filesUploaded} uploaded, ${syncResult.filesDeleted} removed)`,
1782
- );
1783
- } else {
1784
- syncSpin.success("Source already synced");
1785
- }
1786
- }
1787
- } catch {
1788
- syncSpin.stop();
1789
- reporter.warn("Cloud sync failed (deploy succeeded)");
1790
- reporter.info("Run: jack sync");
1791
- }
1792
- }
1793
- }
1814
+ // Note: Auto-sync to User R2 was removed for managed mode.
1815
+ // Managed projects use control-plane source.zip for clone instead.
1816
+ // BYO users can run 'jack sync' manually if needed.
1794
1817
 
1795
1818
  // Ensure project is linked locally for discovery
1796
1819
  try {
@@ -1936,7 +1959,9 @@ export async function getProjectStatus(
1936
1959
 
1937
1960
  /**
1938
1961
  * Scan for stale project paths.
1939
- * Checks for paths in the index that no longer exist on disk or don't have valid links.
1962
+ * Checks for:
1963
+ * 1. Paths in the index that no longer have wrangler config (dir deleted/moved)
1964
+ * 2. Managed projects where the cloud project no longer exists (orphaned links)
1940
1965
  * Returns total project count and stale entries with reasons.
1941
1966
  */
1942
1967
  export async function scanStaleProjects(): Promise<StaleProjectScan> {
@@ -1945,31 +1970,61 @@ export async function scanStaleProjects(): Promise<StaleProjectScan> {
1945
1970
  const stale: StaleProject[] = [];
1946
1971
  let totalPaths = 0;
1947
1972
 
1973
+ // Get list of valid managed project IDs (if logged in)
1974
+ let validManagedIds: Set<string> = new Set();
1975
+ try {
1976
+ const { isLoggedIn } = await import("./auth/store.ts");
1977
+ if (await isLoggedIn()) {
1978
+ const managedProjects = await listManagedProjects();
1979
+ validManagedIds = new Set(managedProjects.map((p) => p.id));
1980
+ }
1981
+ } catch {
1982
+ // Control plane unavailable, skip orphan detection
1983
+ }
1984
+
1948
1985
  for (const projectId of projectIds) {
1949
1986
  const paths = allPaths[projectId] || [];
1950
1987
  totalPaths += paths.length;
1951
1988
 
1952
1989
  for (const projectPath of paths) {
1953
- // Check if path exists and has valid link
1990
+ // Check if path exists and has valid wrangler config
1954
1991
  const hasWranglerConfig =
1955
1992
  existsSync(join(projectPath, "wrangler.jsonc")) ||
1956
1993
  existsSync(join(projectPath, "wrangler.toml")) ||
1957
1994
  existsSync(join(projectPath, "wrangler.json"));
1958
1995
 
1959
1996
  if (!hasWranglerConfig) {
1960
- // Try to get project name from the path
1961
- let name = projectPath.split("/").pop() || projectId;
1962
- try {
1963
- name = await getProjectNameFromDir(projectPath);
1964
- } catch {
1965
- // Use path basename as fallback
1966
- }
1967
-
1997
+ // Type 1: No wrangler config at path (dir deleted/moved)
1998
+ const name = projectPath.split("/").pop() || projectId;
1968
1999
  stale.push({
1969
2000
  name,
1970
- reason: "worker not deployed",
2001
+ reason: "directory missing or no wrangler config",
1971
2002
  workerUrl: null,
1972
2003
  });
2004
+ continue;
2005
+ }
2006
+
2007
+ // Check for Type 2: Managed project link pointing to deleted cloud project
2008
+ try {
2009
+ const link = await readProjectLink(projectPath);
2010
+ if (link?.deploy_mode === "managed" && validManagedIds.size > 0) {
2011
+ if (!validManagedIds.has(link.project_id)) {
2012
+ // Orphaned managed link - cloud project doesn't exist
2013
+ let name = projectPath.split("/").pop() || projectId;
2014
+ try {
2015
+ name = await getProjectNameFromDir(projectPath);
2016
+ } catch {
2017
+ // Use path basename as fallback
2018
+ }
2019
+ stale.push({
2020
+ name,
2021
+ reason: "cloud project deleted",
2022
+ workerUrl: null,
2023
+ });
2024
+ }
2025
+ }
2026
+ } catch {
2027
+ // Can't read link, skip
1973
2028
  }
1974
2029
  }
1975
2030
  }
@@ -382,7 +382,7 @@ export async function listAllProjects(): Promise<ResolvedProject[]> {
382
382
  !project.sources.controlPlane
383
383
  ) {
384
384
  project.status = "error";
385
- project.errorMessage = "Project not found in jack cloud. Run: jack unlink && jack ship";
385
+ project.errorMessage = "Project not found in jack cloud";
386
386
  }
387
387
  }
388
388
  } catch {
@@ -155,6 +155,8 @@ export async function createDatabase(
155
155
  let databaseId: string;
156
156
  let created = true;
157
157
 
158
+ let actualDatabaseName = databaseName;
159
+
158
160
  if (link.deploy_mode === "managed") {
159
161
  // Managed mode: call control plane
160
162
  // Note: Control plane will reuse existing DB if name matches
@@ -163,7 +165,8 @@ export async function createDatabase(
163
165
  bindingName,
164
166
  });
165
167
  databaseId = resource.provider_id;
166
- // Control plane always creates for now; could add reuse logic there too
168
+ // Use the actual name from control plane (may differ from CLI-generated name)
169
+ actualDatabaseName = resource.resource_name;
167
170
  } else {
168
171
  // BYO mode: use wrangler d1 create (checks for existing first)
169
172
  const result = await createDatabaseViaWrangler(databaseName);
@@ -174,12 +177,12 @@ export async function createDatabase(
174
177
  // Update wrangler.jsonc with the new binding
175
178
  await addD1Binding(wranglerPath, {
176
179
  binding: bindingName,
177
- database_name: databaseName,
180
+ database_name: actualDatabaseName,
178
181
  database_id: databaseId,
179
182
  });
180
183
 
181
184
  return {
182
- databaseName,
185
+ databaseName: actualDatabaseName,
183
186
  databaseId,
184
187
  bindingName,
185
188
  created,
@@ -7,6 +7,7 @@ import { join } from "node:path";
7
7
  import { $ } from "bun";
8
8
  import pkg from "../../package.json";
9
9
  import { CONFIG_DIR } from "./config.ts";
10
+ import { debug } from "./debug.ts";
10
11
 
11
12
  const VERSION_CACHE_PATH = join(CONFIG_DIR, "version-cache.json");
12
13
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -123,8 +124,17 @@ export async function performUpdate(): Promise<{
123
124
  }> {
124
125
  try {
125
126
  // Run bun add -g to update
127
+ debug(`Executing: bun add -g ${PACKAGE_NAME}@latest`);
126
128
  const result = await $`bun add -g ${PACKAGE_NAME}@latest`.nothrow().quiet();
127
129
 
130
+ debug(`Exit code: ${result.exitCode}`);
131
+ if (result.stdout.toString()) {
132
+ debug(`stdout: ${result.stdout.toString()}`);
133
+ }
134
+ if (result.stderr.toString()) {
135
+ debug(`stderr: ${result.stderr.toString()}`);
136
+ }
137
+
128
138
  if (result.exitCode !== 0) {
129
139
  return {
130
140
  success: false,
@@ -133,12 +143,15 @@ export async function performUpdate(): Promise<{
133
143
  }
134
144
 
135
145
  // Verify the new version
146
+ debug("Verifying installed version...");
136
147
  const newVersionResult = await $`bun pm ls -g`.nothrow().quiet();
137
148
  const output = newVersionResult.stdout.toString();
149
+ debug(`bun pm ls -g output: ${output.slice(0, 500)}`);
138
150
 
139
151
  // Try to extract version from output
140
152
  const versionMatch = output.match(/@getjack\/jack@(\d+\.\d+\.\d+)/);
141
153
  const newVersion = versionMatch?.[1];
154
+ debug(`Extracted version: ${newVersion ?? "not found"}`);
142
155
 
143
156
  // Clear version cache so next check gets fresh data
144
157
  try {
@@ -152,6 +165,7 @@ export async function performUpdate(): Promise<{
152
165
  version: newVersion,
153
166
  };
154
167
  } catch (err) {
168
+ debug(`Update error: ${err instanceof Error ? err.message : String(err)}`);
155
169
  return {
156
170
  success: false,
157
171
  error: err instanceof Error ? err.message : "Unknown error",
@@ -457,3 +457,193 @@ function findLineCommentStart(line: string): number {
457
457
 
458
458
  return -1;
459
459
  }
460
+
461
+ /**
462
+ * Remove a D1 database binding from wrangler.jsonc by database_name.
463
+ * Preserves comments and formatting.
464
+ *
465
+ * @returns true if binding was found and removed, false if not found
466
+ */
467
+ export async function removeD1Binding(configPath: string, databaseName: string): Promise<boolean> {
468
+ if (!existsSync(configPath)) {
469
+ throw new Error(`wrangler.jsonc not found at ${configPath}. Cannot remove binding.`);
470
+ }
471
+
472
+ const content = await Bun.file(configPath).text();
473
+
474
+ // Parse to understand existing structure
475
+ const config = parseJsonc<WranglerConfig>(content);
476
+
477
+ // Check if d1_databases exists and has entries
478
+ if (!config.d1_databases || !Array.isArray(config.d1_databases)) {
479
+ return false;
480
+ }
481
+
482
+ // Find the binding to remove
483
+ const bindingIndex = config.d1_databases.findIndex((db) => db.database_name === databaseName);
484
+
485
+ if (bindingIndex === -1) {
486
+ return false; // Binding not found
487
+ }
488
+
489
+ // Use text manipulation to remove the binding while preserving formatting
490
+ const newContent = removeD1DatabaseEntryFromContent(content, databaseName);
491
+
492
+ if (newContent === content) {
493
+ return false; // Nothing changed
494
+ }
495
+
496
+ await Bun.write(configPath, newContent);
497
+ return true;
498
+ }
499
+
500
+ /**
501
+ * Remove a specific D1 database entry from the d1_databases array in content.
502
+ * Handles comma placement and preserves comments.
503
+ */
504
+ function removeD1DatabaseEntryFromContent(content: string, databaseName: string): string {
505
+ // Find the d1_databases array
506
+ const d1Match = content.match(/"d1_databases"\s*:\s*\[/);
507
+ if (!d1Match || d1Match.index === undefined) {
508
+ return content;
509
+ }
510
+
511
+ const arrayStartIndex = d1Match.index + d1Match[0].length;
512
+ const closingBracketIndex = findMatchingBracket(content, arrayStartIndex - 1, "[", "]");
513
+
514
+ if (closingBracketIndex === -1) {
515
+ return content;
516
+ }
517
+
518
+ const arrayContent = content.slice(arrayStartIndex, closingBracketIndex);
519
+
520
+ // Find the object containing this database_name
521
+ const escapedName = databaseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
522
+ const dbNamePattern = new RegExp(`"database_name"\\s*:\\s*"${escapedName}"`);
523
+
524
+ const match = dbNamePattern.exec(arrayContent);
525
+ if (!match) {
526
+ return content;
527
+ }
528
+
529
+ // Find the enclosing object boundaries
530
+ const matchPosInArray = match.index;
531
+ const objectStart = findObjectStartBefore(arrayContent, matchPosInArray);
532
+ const objectEnd = findObjectEndAfter(arrayContent, matchPosInArray);
533
+
534
+ if (objectStart === -1 || objectEnd === -1) {
535
+ return content;
536
+ }
537
+
538
+ // Determine comma handling
539
+ let removeStart = objectStart;
540
+ let removeEnd = objectEnd + 1;
541
+
542
+ // Check for trailing comma after the object
543
+ const afterObject = arrayContent.slice(objectEnd + 1);
544
+ const trailingCommaMatch = afterObject.match(/^\s*,/);
545
+
546
+ // Check for leading comma before the object
547
+ const beforeObject = arrayContent.slice(0, objectStart);
548
+ const leadingCommaMatch = beforeObject.match(/,\s*$/);
549
+
550
+ if (trailingCommaMatch) {
551
+ // Remove trailing comma
552
+ removeEnd = objectEnd + 1 + trailingCommaMatch[0].length;
553
+ } else if (leadingCommaMatch) {
554
+ // Remove leading comma
555
+ removeStart = objectStart - leadingCommaMatch[0].length;
556
+ }
557
+
558
+ // Build new array content
559
+ const newArrayContent = arrayContent.slice(0, removeStart) + arrayContent.slice(removeEnd);
560
+
561
+ // Check if array is now effectively empty (only whitespace/comments)
562
+ const trimmedArray = newArrayContent.replace(/\/\/[^\n]*/g, "").trim();
563
+ if (trimmedArray === "" || trimmedArray === "[]") {
564
+ // Remove the entire d1_databases property
565
+ return removeD1DatabasesProperty(content, d1Match.index, closingBracketIndex);
566
+ }
567
+
568
+ return content.slice(0, arrayStartIndex) + newArrayContent + content.slice(closingBracketIndex);
569
+ }
570
+
571
+ /**
572
+ * Find the start of the object (opening brace) before the given position.
573
+ */
574
+ function findObjectStartBefore(content: string, fromPos: number): number {
575
+ let depth = 0;
576
+ for (let i = fromPos; i >= 0; i--) {
577
+ const char = content[i];
578
+ if (char === "}") depth++;
579
+ if (char === "{") {
580
+ if (depth === 0) return i;
581
+ depth--;
582
+ }
583
+ }
584
+ return -1;
585
+ }
586
+
587
+ /**
588
+ * Find the end of the object (closing brace) after the given position.
589
+ */
590
+ function findObjectEndAfter(content: string, fromPos: number): number {
591
+ let depth = 0;
592
+ let inString = false;
593
+ let escaped = false;
594
+
595
+ for (let i = fromPos; i < content.length; i++) {
596
+ const char = content[i];
597
+
598
+ if (inString) {
599
+ if (escaped) {
600
+ escaped = false;
601
+ } else if (char === "\\") {
602
+ escaped = true;
603
+ } else if (char === '"') {
604
+ inString = false;
605
+ }
606
+ continue;
607
+ }
608
+
609
+ if (char === '"') {
610
+ inString = true;
611
+ continue;
612
+ }
613
+
614
+ if (char === "{") depth++;
615
+ if (char === "}") {
616
+ if (depth === 0) return i;
617
+ depth--;
618
+ }
619
+ }
620
+ return -1;
621
+ }
622
+
623
+ /**
624
+ * Remove the entire d1_databases property when it becomes empty.
625
+ */
626
+ function removeD1DatabasesProperty(
627
+ content: string,
628
+ propertyStart: number,
629
+ arrayEnd: number,
630
+ ): string {
631
+ let removeStart = propertyStart;
632
+ let removeEnd = arrayEnd + 1;
633
+
634
+ // Look backward for a comma to remove
635
+ const beforeProperty = content.slice(0, propertyStart);
636
+ const leadingCommaMatch = beforeProperty.match(/,\s*$/);
637
+
638
+ // Look forward for a trailing comma
639
+ const afterProperty = content.slice(arrayEnd + 1);
640
+ const trailingCommaMatch = afterProperty.match(/^\s*,/);
641
+
642
+ if (leadingCommaMatch) {
643
+ removeStart = propertyStart - leadingCommaMatch[0].length;
644
+ } else if (trailingCommaMatch) {
645
+ removeEnd = arrayEnd + 1 + trailingCommaMatch[0].length;
646
+ }
647
+
648
+ return content.slice(0, removeStart) + content.slice(removeEnd);
649
+ }