@getjack/jack 0.1.13 → 0.1.15

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.
@@ -41,8 +41,8 @@ import {
41
41
  } from "./config-generator.ts";
42
42
  import { getSyncConfig } from "./config.ts";
43
43
  import { deleteManagedProject } from "./control-plane.ts";
44
- import { debug, isDebug } from "./debug.ts";
45
- import { validateModeAvailability } from "./deploy-mode.ts";
44
+ import { debug, isDebug, printTimingSummary, timerEnd, timerStart } from "./debug.ts";
45
+ import { ensureWranglerInstalled, validateModeAvailability } from "./deploy-mode.ts";
46
46
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
47
47
  import { JackError, JackErrorCode } from "./errors.ts";
48
48
  import { type HookOutput, runHook } from "./hooks.ts";
@@ -477,14 +477,23 @@ async function runAutoDetectFlow(
477
477
  }
478
478
 
479
479
  // Interactive mode - prompt for project name
480
- const { input } = await import("@inquirer/prompts");
480
+ const { isCancel, text } = await import("@clack/prompts");
481
481
 
482
482
  console.error("");
483
- const projectName = await input({
483
+ const projectNameInput = await text({
484
484
  message: "Project name:",
485
- default: defaultName,
485
+ defaultValue: defaultName,
486
486
  });
487
487
 
488
+ if (isCancel(projectNameInput)) {
489
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "Deployment cancelled", undefined, {
490
+ exitCode: 0,
491
+ reported: true,
492
+ });
493
+ }
494
+
495
+ const projectName = projectNameInput;
496
+
488
497
  const slugifiedName = slugify(projectName.trim());
489
498
  const runjackUrl = ownerUsername
490
499
  ? `https://${ownerUsername}-${slugifiedName}.runjack.xyz`
@@ -580,6 +589,18 @@ export async function createProject(
580
589
  process.env.CI === "1";
581
590
  const interactive = interactiveOption ?? !isCi;
582
591
 
592
+ // Track timings for each step (shown with --debug)
593
+ const timings: Array<{ label: string; duration: number }> = [];
594
+
595
+ // Fast local validation first - check directory before any network calls
596
+ const nameWasProvided = name !== undefined;
597
+ if (nameWasProvided) {
598
+ const targetDir = resolve(name);
599
+ if (existsSync(targetDir)) {
600
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, `Directory ${name} already exists`);
601
+ }
602
+ }
603
+
583
604
  // Check if jack init was run (throws if not)
584
605
  const { isInitialized } = await import("../commands/init.ts");
585
606
  const initialized = await isInitialized();
@@ -588,44 +609,58 @@ export async function createProject(
588
609
  }
589
610
 
590
611
  // Auth gate - check/prompt for authentication before any work
612
+ timerStart("auth-gate");
591
613
  const { ensureAuthForCreate } = await import("./auth/ensure-auth.ts");
592
614
  const authResult = await ensureAuthForCreate({
593
615
  interactive,
594
616
  forceManaged: options.managed,
595
617
  forceByo: options.byo,
596
618
  });
619
+ timings.push({ label: "Auth gate", duration: timerEnd("auth-gate") });
597
620
 
598
621
  // Use authResult.mode (auth gate handles mode resolution)
599
622
  const deployMode = authResult.mode;
600
623
 
601
624
  // Close the "Starting..." spinner from new.ts
602
625
  reporter.stop();
603
- reporter.success("Initialized");
626
+ if (deployMode === "managed") {
627
+ reporter.success("Connected to jack cloud");
628
+ } else {
629
+ reporter.success("Ready");
630
+ }
604
631
 
605
632
  // Generate or use provided name
606
- const nameWasProvided = name !== undefined;
607
633
  const projectName = name ?? generateProjectName();
608
634
  const targetDir = resolve(projectName);
609
635
 
610
- // Check directory doesn't exist
611
- if (existsSync(targetDir)) {
636
+ // Check directory doesn't exist (only needed for auto-generated names now)
637
+ if (!nameWasProvided && existsSync(targetDir)) {
612
638
  throw new JackError(JackErrorCode.VALIDATION_ERROR, `Directory ${projectName} already exists`);
613
639
  }
614
640
 
615
641
  // Early slug availability check for managed mode (only if user provided explicit name)
616
642
  // Skip for auto-generated names - collision is rare, control plane will catch it anyway
617
643
  if (deployMode === "managed" && nameWasProvided) {
644
+ timerStart("slug-check");
618
645
  reporter.start("Checking name availability...");
619
- const { checkAvailability } = await import("./project-resolver.ts");
620
- const { available, existingProject } = await checkAvailability(projectName);
621
- reporter.stop();
622
- if (available) {
646
+
647
+ // First check if the slug is available globally (includes system-reserved names)
648
+ const { checkSlugAvailability } = await import("./control-plane.ts");
649
+ const slugCheck = await checkSlugAvailability(projectName);
650
+
651
+ if (slugCheck.available) {
652
+ timings.push({ label: "Slug check", duration: timerEnd("slug-check") });
653
+ reporter.stop();
623
654
  reporter.success("Name available");
624
- }
655
+ } else {
656
+ // Slug not available - check if it's the user's own project (for linking flow)
657
+ const { checkAvailability } = await import("./project-resolver.ts");
658
+ const { existingProject } = await checkAvailability(projectName);
659
+ timings.push({ label: "Slug check", duration: timerEnd("slug-check") });
660
+ reporter.stop();
625
661
 
626
- if (!available && existingProject) {
627
- // Project exists remotely but not locally - offer to link
628
- if (existingProject.sources.controlPlane && !existingProject.sources.filesystem) {
662
+ if (existingProject?.sources.controlPlane && !existingProject.sources.filesystem) {
663
+ // It's the user's project on jack cloud but not locally - offer to link
629
664
  if (interactive) {
630
665
  const { promptSelect } = await import("./hooks.ts");
631
666
  console.error("");
@@ -636,7 +671,6 @@ export async function createProject(
636
671
 
637
672
  if (choice === 0) {
638
673
  // User chose to link - proceed with project creation
639
- // The project will be linked locally when files are created
640
674
  reporter.success(`Linking to existing project: ${existingProject.url || projectName}`);
641
675
  // Continue with project creation - user wants to link
642
676
  } else {
@@ -656,13 +690,20 @@ export async function createProject(
656
690
  `Try a different name: jack new ${projectName}-2`,
657
691
  );
658
692
  }
659
- } else {
660
- // Project exists in registry with local path - it's truly taken
693
+ } else if (existingProject) {
694
+ // Project exists in registry with local path - it's truly taken by user
661
695
  throw new JackError(
662
696
  JackErrorCode.VALIDATION_ERROR,
663
697
  `Project "${projectName}" already exists`,
664
698
  `Try a different name: jack new ${projectName}-2`,
665
699
  );
700
+ } else {
701
+ // Slug taken but not by this user (reserved or another user's project)
702
+ throw new JackError(
703
+ JackErrorCode.VALIDATION_ERROR,
704
+ `Name "${projectName}" is not available`,
705
+ `Try a different name: jack new ${projectName}-2`,
706
+ );
666
707
  }
667
708
  }
668
709
  }
@@ -756,6 +797,7 @@ export async function createProject(
756
797
  }
757
798
 
758
799
  // Load template with origin tracking for lineage
800
+ timerStart("template-load");
759
801
  let template: Template;
760
802
  let templateOrigin: TemplateOrigin;
761
803
  try {
@@ -763,12 +805,14 @@ export async function createProject(
763
805
  template = resolved.template;
764
806
  templateOrigin = resolved.origin;
765
807
  } catch (err) {
808
+ timerEnd("template-load");
766
809
  reporter.stop();
767
810
  const message = err instanceof Error ? err.message : String(err);
768
811
  throw new JackError(JackErrorCode.TEMPLATE_NOT_FOUND, message);
769
812
  }
770
813
 
771
814
  const rendered = renderTemplate(template, { name: projectName });
815
+ timings.push({ label: "Template load", duration: timerEnd("template-load") });
772
816
 
773
817
  // Handle template-specific secrets
774
818
  const secretsToUse: Record<string, string> = {};
@@ -817,29 +861,28 @@ export async function createProject(
817
861
 
818
862
  // Prompt user
819
863
  reporter.stop();
820
- const { input, select } = await import("@inquirer/prompts");
864
+ const { isCancel, select, text } = await import("@clack/prompts");
821
865
  console.error("");
822
866
  console.error(` ${optionalSecret.description}`);
823
867
  if (optionalSecret.setupUrl) {
824
868
  console.error(` Setup: ${optionalSecret.setupUrl}`);
825
869
  }
826
870
  console.error("");
827
- console.error(" Esc to skip\n");
828
871
 
829
872
  const choice = await select({
830
873
  message: `Add ${optionalSecret.name}?`,
831
- choices: [
832
- { name: "1. Yes", value: "yes" },
833
- { name: "2. Skip", value: "skip" },
874
+ options: [
875
+ { label: "Yes", value: "yes" },
876
+ { label: "Skip", value: "skip" },
834
877
  ],
835
878
  });
836
879
 
837
- if (choice === "yes") {
838
- const value = await input({
880
+ if (!isCancel(choice) && choice === "yes") {
881
+ const value = await text({
839
882
  message: `Enter ${optionalSecret.name}:`,
840
883
  });
841
884
 
842
- if (value.trim()) {
885
+ if (!isCancel(value) && value.trim()) {
843
886
  secretsToUse[optionalSecret.name] = value.trim();
844
887
  // Save to global secrets for reuse
845
888
  await saveSecrets([
@@ -857,348 +900,380 @@ export async function createProject(
857
900
  }
858
901
  }
859
902
 
860
- // Write all template files
861
- for (const [filePath, content] of Object.entries(rendered.files)) {
862
- await Bun.write(join(targetDir, filePath), content);
863
- }
903
+ // Track if we created the directory (for cleanup on failure)
904
+ let directoryCreated = false;
864
905
 
865
- // Preflight: check D1 capacity before spending time on installs (BYO only)
866
- reporter.stop();
867
- if (deployMode === "byo") {
868
- await preflightD1Capacity(targetDir, reporter, interactive);
869
- }
870
- reporter.start("Creating project...");
906
+ try {
907
+ // Write all template files
908
+ timerStart("file-write");
909
+ for (const [filePath, content] of Object.entries(rendered.files)) {
910
+ await Bun.write(join(targetDir, filePath), content);
911
+ directoryCreated = true; // Directory now exists
912
+ }
871
913
 
872
- // Write secrets files (.env for Vite, .dev.vars for wrangler local, .secrets.json for wrangler bulk)
873
- if (Object.keys(secretsToUse).length > 0) {
874
- const envContent = generateEnvFile(secretsToUse);
875
- const jsonContent = generateSecretsJson(secretsToUse);
876
- await Bun.write(join(targetDir, ".env"), envContent);
877
- await Bun.write(join(targetDir, ".dev.vars"), envContent);
878
- await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
914
+ // Preflight: check D1 capacity before spending time on installs (BYO only)
915
+ reporter.stop();
916
+ if (deployMode === "byo") {
917
+ await preflightD1Capacity(targetDir, reporter, interactive);
918
+ }
919
+ reporter.start("Creating project...");
879
920
 
880
- const gitignorePath = join(targetDir, ".gitignore");
881
- const gitignoreExists = existsSync(gitignorePath);
921
+ // Write secrets files (.env for Vite, .dev.vars for wrangler local, .secrets.json for wrangler bulk)
922
+ if (Object.keys(secretsToUse).length > 0) {
923
+ const envContent = generateEnvFile(secretsToUse);
924
+ const jsonContent = generateSecretsJson(secretsToUse);
925
+ await Bun.write(join(targetDir, ".env"), envContent);
926
+ await Bun.write(join(targetDir, ".dev.vars"), envContent);
927
+ await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
882
928
 
883
- if (!gitignoreExists) {
884
- await Bun.write(gitignorePath, ".env\n.env.*\n.dev.vars\n.secrets.json\nnode_modules/\n");
885
- } else {
886
- const existingContent = await Bun.file(gitignorePath).text();
887
- if (!existingContent.includes(".env")) {
888
- await Bun.write(
889
- gitignorePath,
890
- `${existingContent}\n.env\n.env.*\n.dev.vars\n.secrets.json\n`,
891
- );
929
+ const gitignorePath = join(targetDir, ".gitignore");
930
+ const gitignoreExists = existsSync(gitignorePath);
931
+
932
+ if (!gitignoreExists) {
933
+ await Bun.write(gitignorePath, ".env\n.env.*\n.dev.vars\n.secrets.json\nnode_modules/\n");
934
+ } else {
935
+ const existingContent = await Bun.file(gitignorePath).text();
936
+ if (!existingContent.includes(".env")) {
937
+ await Bun.write(
938
+ gitignorePath,
939
+ `${existingContent}\n.env\n.env.*\n.dev.vars\n.secrets.json\n`,
940
+ );
941
+ }
892
942
  }
893
943
  }
894
- }
944
+ timings.push({ label: "File write", duration: timerEnd("file-write") });
895
945
 
896
- // Generate agent context files
897
- let activeAgents = await getActiveAgents();
898
- if (activeAgents.length > 0) {
899
- const validation = await validateAgentPaths();
946
+ // Generate agent context files
947
+ let activeAgents = await getActiveAgents();
948
+ if (activeAgents.length > 0) {
949
+ const validation = await validateAgentPaths();
900
950
 
901
- if (validation.invalid.length > 0) {
902
- // Silently filter out agents with missing paths
903
- // User can run 'jack agents scan' to see/fix agent config
904
- activeAgents = activeAgents.filter(
905
- ({ id }) => !validation.invalid.some((inv) => inv.id === id),
906
- );
907
- }
951
+ if (validation.invalid.length > 0) {
952
+ // Silently filter out agents with missing paths
953
+ // User can run 'jack agents scan' to see/fix agent config
954
+ activeAgents = activeAgents.filter(
955
+ ({ id }) => !validation.invalid.some((inv) => inv.id === id),
956
+ );
957
+ }
908
958
 
909
- if (activeAgents.length > 0) {
910
- await generateAgentFiles(targetDir, projectName, template, activeAgents);
911
- const agentNames = activeAgents.map(({ definition }) => definition.name).join(", ");
912
- reporter.stop();
913
- reporter.success(`Generated context for: ${agentNames}`);
914
- reporter.start("Creating project...");
959
+ if (activeAgents.length > 0) {
960
+ await generateAgentFiles(targetDir, projectName, template, activeAgents);
961
+ const agentNames = activeAgents.map(({ definition }) => definition.name).join(", ");
962
+ reporter.stop();
963
+ reporter.success(`Generated context for: ${agentNames}`);
964
+ reporter.start("Creating project...");
965
+ }
915
966
  }
916
- }
917
967
 
918
- reporter.stop();
919
- reporter.success(`Created ${projectName}/`);
968
+ reporter.stop();
969
+ reporter.success(`Created ${projectName}/`);
920
970
 
921
- // Parallel setup for managed mode: install + remote creation
922
- let remoteResult: ManagedCreateResult | undefined;
971
+ // Parallel setup for managed mode: install + remote creation
972
+ let remoteResult: ManagedCreateResult | undefined;
923
973
 
924
- if (deployMode === "managed") {
925
- // Run install and remote creation in parallel
926
- reporter.start("Setting up project...");
974
+ if (deployMode === "managed") {
975
+ // Run install and remote creation in parallel
976
+ timerStart("parallel-setup");
977
+ reporter.start("Setting up project...");
927
978
 
928
- try {
929
- const result = await runParallelSetup(targetDir, projectName, {
930
- template: resolvedTemplate || "hello",
931
- usePrebuilt: templateOrigin.type === "builtin", // Only builtin templates have prebuilt bundles
932
- });
933
- remoteResult = result.remoteResult;
934
- reporter.stop();
935
- reporter.success("Project setup complete");
936
- } catch (err) {
937
- reporter.stop();
938
- if (err instanceof JackError) {
939
- reporter.warn(err.suggestion ?? err.message);
979
+ try {
980
+ const result = await runParallelSetup(targetDir, projectName, {
981
+ template: resolvedTemplate || "hello",
982
+ usePrebuilt: templateOrigin.type === "builtin", // Only builtin templates have prebuilt bundles
983
+ });
984
+ remoteResult = result.remoteResult;
985
+ timings.push({ label: "Parallel setup", duration: timerEnd("parallel-setup") });
986
+ reporter.stop();
987
+ reporter.success("Project setup complete");
988
+ } catch (err) {
989
+ timerEnd("parallel-setup");
990
+ reporter.stop();
991
+ if (err instanceof JackError) {
992
+ reporter.warn(err.suggestion ?? err.message);
993
+ throw err;
994
+ }
940
995
  throw err;
941
996
  }
942
- throw err;
943
- }
944
- } else {
945
- // BYO mode: just install dependencies (unchanged from current)
946
- reporter.start("Installing dependencies...");
947
-
948
- const install = Bun.spawn(["bun", "install"], {
949
- cwd: targetDir,
950
- stdout: "ignore",
951
- stderr: "ignore",
952
- });
953
- await install.exited;
997
+ } else {
998
+ // BYO mode: just install dependencies (unchanged from current)
999
+ timerStart("bun-install");
1000
+ reporter.start("Installing dependencies...");
954
1001
 
955
- if (install.exitCode !== 0) {
956
- reporter.stop();
957
- reporter.warn("Failed to install dependencies, run: bun install");
958
- throw new JackError(
959
- JackErrorCode.BUILD_FAILED,
960
- "Dependency installation failed",
961
- "Run: bun install",
962
- { exitCode: 0, reported: hasReporter },
963
- );
964
- }
1002
+ const install = Bun.spawn(["bun", "install"], {
1003
+ cwd: targetDir,
1004
+ stdout: "ignore",
1005
+ stderr: "ignore",
1006
+ });
1007
+ await install.exited;
965
1008
 
966
- reporter.stop();
967
- reporter.success("Dependencies installed");
968
- }
1009
+ if (install.exitCode !== 0) {
1010
+ timerEnd("bun-install");
1011
+ reporter.stop();
1012
+ reporter.warn("Failed to install dependencies, run: bun install");
1013
+ throw new JackError(
1014
+ JackErrorCode.BUILD_FAILED,
1015
+ "Dependency installation failed",
1016
+ "Run: bun install",
1017
+ { exitCode: 0, reported: hasReporter },
1018
+ );
1019
+ }
969
1020
 
970
- // Run pre-deploy hooks
971
- if (template.hooks?.preDeploy?.length) {
972
- const hookContext = { projectName, projectDir: targetDir };
973
- const hookResult = await runHook(template.hooks.preDeploy, hookContext, {
974
- interactive,
975
- output: reporter,
976
- });
977
- if (!hookResult.success) {
978
- reporter.error("Pre-deploy checks failed");
979
- throw new JackError(JackErrorCode.VALIDATION_ERROR, "Pre-deploy checks failed", undefined, {
980
- exitCode: 0,
981
- reported: hasReporter,
982
- });
1021
+ timings.push({ label: "Bun install", duration: timerEnd("bun-install") });
1022
+ reporter.stop();
1023
+ reporter.success("Dependencies installed");
983
1024
  }
984
- }
985
1025
 
986
- // One-shot agent customization if intent was provided
987
- if (intentPhrase) {
988
- const oneShotAgent = await getOneShotAgent();
989
-
990
- if (oneShotAgent) {
991
- const agentDefinition = getAgentDefinition(oneShotAgent);
992
- const agentLabel = agentDefinition?.name ?? oneShotAgent;
993
- reporter.info(`Customizing with ${agentLabel}`);
994
- reporter.info(`Intent: ${intentPhrase}`);
995
- const debugEnabled = isDebug();
996
- const customizationSpinner = debugEnabled ? null : reporter.spinner("Customizing...");
997
-
998
- // Track customization start
999
- track(Events.INTENT_CUSTOMIZATION_STARTED, { agent: oneShotAgent });
1000
-
1001
- const result = await runAgentOneShot(oneShotAgent, targetDir, intentPhrase, {
1002
- info: reporter.info,
1003
- warn: reporter.warn,
1004
- status: customizationSpinner
1005
- ? (message) => {
1006
- customizationSpinner.text = message;
1007
- }
1008
- : undefined,
1026
+ // Run pre-deploy hooks
1027
+ if (template.hooks?.preDeploy?.length) {
1028
+ const hookContext = { projectName, projectDir: targetDir };
1029
+ const hookResult = await runHook(template.hooks.preDeploy, hookContext, {
1030
+ interactive,
1031
+ output: reporter,
1009
1032
  });
1010
-
1011
- if (customizationSpinner) {
1012
- customizationSpinner.stop();
1013
- }
1014
- if (result.success) {
1015
- reporter.success("Project customized");
1016
- // Track successful customization
1017
- track(Events.INTENT_CUSTOMIZATION_COMPLETED, { agent: oneShotAgent });
1018
- } else {
1019
- reporter.warn(`Customization skipped: ${result.error ?? "unknown error"}`);
1020
- // Track failed customization
1021
- track(Events.INTENT_CUSTOMIZATION_FAILED, {
1022
- agent: oneShotAgent,
1023
- error_type: "agent_error",
1033
+ if (!hookResult.success) {
1034
+ reporter.error("Pre-deploy checks failed");
1035
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "Pre-deploy checks failed", undefined, {
1036
+ exitCode: 0,
1037
+ reported: hasReporter,
1024
1038
  });
1025
1039
  }
1026
- } else {
1027
- reporter.info?.("No compatible agent for customization (Claude Code or Codex required)");
1028
1040
  }
1029
- }
1030
1041
 
1031
- let workerUrl: string | null = null;
1042
+ // One-shot agent customization if intent was provided
1043
+ if (intentPhrase) {
1044
+ const oneShotAgent = await getOneShotAgent();
1045
+
1046
+ if (oneShotAgent) {
1047
+ const agentDefinition = getAgentDefinition(oneShotAgent);
1048
+ const agentLabel = agentDefinition?.name ?? oneShotAgent;
1049
+ reporter.info(`Customizing with ${agentLabel}`);
1050
+ reporter.info(`Intent: ${intentPhrase}`);
1051
+ const debugEnabled = isDebug();
1052
+ const customizationSpinner = debugEnabled ? null : reporter.spinner("Customizing...");
1053
+
1054
+ // Track customization start
1055
+ track(Events.INTENT_CUSTOMIZATION_STARTED, { agent: oneShotAgent });
1056
+
1057
+ const result = await runAgentOneShot(oneShotAgent, targetDir, intentPhrase, {
1058
+ info: reporter.info,
1059
+ warn: reporter.warn,
1060
+ status: customizationSpinner
1061
+ ? (message) => {
1062
+ customizationSpinner.text = message;
1063
+ }
1064
+ : undefined,
1065
+ });
1032
1066
 
1033
- // Deploy based on mode
1034
- if (deployMode === "managed") {
1035
- // Managed mode: remote was already created in parallel setup
1036
- if (!remoteResult) {
1037
- throw new JackError(
1038
- JackErrorCode.VALIDATION_ERROR,
1039
- "Managed project was not created",
1040
- "This is an internal error - please report it",
1041
- );
1067
+ if (customizationSpinner) {
1068
+ customizationSpinner.stop();
1069
+ }
1070
+ if (result.success) {
1071
+ reporter.success("Project customized");
1072
+ // Track successful customization
1073
+ track(Events.INTENT_CUSTOMIZATION_COMPLETED, { agent: oneShotAgent });
1074
+ } else {
1075
+ reporter.warn(`Customization skipped: ${result.error ?? "unknown error"}`);
1076
+ // Track failed customization
1077
+ track(Events.INTENT_CUSTOMIZATION_FAILED, {
1078
+ agent: oneShotAgent,
1079
+ error_type: "agent_error",
1080
+ });
1081
+ }
1082
+ } else {
1083
+ reporter.info?.("No compatible agent for customization (Claude Code or Codex required)");
1084
+ }
1042
1085
  }
1043
1086
 
1044
- // Fetch username for link storage
1045
- const { getCurrentUserProfile } = await import("./control-plane.ts");
1046
- const profile = await getCurrentUserProfile();
1047
- const ownerUsername = profile?.username ?? undefined;
1087
+ let workerUrl: string | null = null;
1048
1088
 
1049
- // Link project locally and register path
1050
- try {
1051
- await linkProject(targetDir, remoteResult.projectId, "managed", ownerUsername);
1052
- await writeTemplateMetadata(targetDir, templateOrigin);
1053
- await registerPath(remoteResult.projectId, targetDir);
1054
- } catch (err) {
1055
- debug("Failed to link managed project:", err);
1056
- }
1057
-
1058
- // Check if prebuilt deployment succeeded
1059
- if (remoteResult.status === "live") {
1060
- // Prebuilt succeeded - skip the fresh build
1061
- workerUrl = remoteResult.runjackUrl;
1062
- reporter.success(`Deployed: ${workerUrl}`);
1063
- } else {
1064
- // Prebuilt not available - fall back to fresh build
1065
- if (remoteResult.prebuiltFailed) {
1066
- // Show debug info about why prebuilt failed
1067
- const errorDetail = remoteResult.prebuiltError ? ` (${remoteResult.prebuiltError})` : "";
1068
- debug(`Prebuilt failed${errorDetail}`);
1069
- reporter.info("Pre-built not available, building fresh...");
1089
+ // Deploy based on mode
1090
+ timerStart("deploy");
1091
+ if (deployMode === "managed") {
1092
+ // Managed mode: remote was already created in parallel setup
1093
+ if (!remoteResult) {
1094
+ throw new JackError(
1095
+ JackErrorCode.VALIDATION_ERROR,
1096
+ "Managed project was not created",
1097
+ "This is an internal error - please report it",
1098
+ );
1070
1099
  }
1071
1100
 
1072
- await deployToManagedProject(remoteResult.projectId, targetDir, reporter);
1073
- workerUrl = remoteResult.runjackUrl;
1074
- reporter.success(`Created: ${workerUrl}`);
1075
- }
1076
- } else {
1077
- // BYO mode: deploy via wrangler
1101
+ // Fetch username for link storage
1102
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1103
+ const profile = await getCurrentUserProfile();
1104
+ const ownerUsername = profile?.username ?? undefined;
1078
1105
 
1079
- // Build first if needed (wrangler needs built assets)
1080
- if (await needsOpenNextBuild(targetDir)) {
1081
- reporter.start("Building assets...");
1106
+ // Link project locally and register path
1082
1107
  try {
1083
- await runOpenNextBuild(targetDir);
1084
- reporter.stop();
1085
- reporter.success("Built assets");
1108
+ await linkProject(targetDir, remoteResult.projectId, "managed", ownerUsername);
1109
+ await writeTemplateMetadata(targetDir, templateOrigin);
1110
+ await registerPath(remoteResult.projectId, targetDir);
1086
1111
  } catch (err) {
1087
- reporter.stop();
1088
- reporter.error("Build failed");
1089
- throw err;
1112
+ debug("Failed to link managed project:", err);
1090
1113
  }
1091
- } else if (await needsViteBuild(targetDir)) {
1092
- reporter.start("Building assets...");
1093
- try {
1094
- await runViteBuild(targetDir);
1095
- reporter.stop();
1096
- reporter.success("Built assets");
1097
- } catch (err) {
1098
- reporter.stop();
1099
- reporter.error("Build failed");
1100
- throw err;
1101
- }
1102
- }
1103
1114
 
1104
- reporter.start("Deploying...");
1105
-
1106
- const deployResult = await runWranglerDeploy(targetDir);
1115
+ // Check if prebuilt deployment succeeded
1116
+ if (remoteResult.status === "live") {
1117
+ // Prebuilt succeeded - skip the fresh build
1118
+ workerUrl = remoteResult.runjackUrl;
1119
+ reporter.success(`Deployed: ${workerUrl}`);
1120
+ } else {
1121
+ // Prebuilt not available - fall back to fresh build
1122
+ if (remoteResult.prebuiltFailed) {
1123
+ // Show debug info about why prebuilt failed
1124
+ const errorDetail = remoteResult.prebuiltError ? ` (${remoteResult.prebuiltError})` : "";
1125
+ debug(`Prebuilt failed${errorDetail}`);
1126
+ reporter.info("Pre-built not available, building fresh...");
1127
+ }
1107
1128
 
1108
- if (deployResult.exitCode !== 0) {
1109
- reporter.stop();
1110
- reporter.error("Deploy failed");
1111
- throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
1112
- exitCode: 0,
1113
- stderr: deployResult.stderr.toString(),
1114
- reported: hasReporter,
1115
- });
1116
- }
1129
+ await deployToManagedProject(remoteResult.projectId, targetDir, reporter);
1130
+ workerUrl = remoteResult.runjackUrl;
1131
+ reporter.success(`Created: ${workerUrl}`);
1132
+ }
1133
+ } else {
1134
+ // BYO mode: deploy via wrangler
1117
1135
 
1118
- // Apply schema.sql after deploy
1119
- if (await hasD1Config(targetDir)) {
1120
- const dbName = await getD1DatabaseName(targetDir);
1121
- if (dbName) {
1136
+ // Build first if needed (wrangler needs built assets)
1137
+ if (await needsOpenNextBuild(targetDir)) {
1138
+ reporter.start("Building assets...");
1122
1139
  try {
1123
- await applySchema(dbName, targetDir);
1140
+ await runOpenNextBuild(targetDir);
1141
+ reporter.stop();
1142
+ reporter.success("Built assets");
1124
1143
  } catch (err) {
1125
- reporter.warn(`Schema application failed: ${err}`);
1126
- reporter.info("Run manually: bun run db:migrate");
1144
+ reporter.stop();
1145
+ reporter.error("Build failed");
1146
+ throw err;
1147
+ }
1148
+ } else if (await needsViteBuild(targetDir)) {
1149
+ reporter.start("Building assets...");
1150
+ try {
1151
+ await runViteBuild(targetDir);
1152
+ reporter.stop();
1153
+ reporter.success("Built assets");
1154
+ } catch (err) {
1155
+ reporter.stop();
1156
+ reporter.error("Build failed");
1157
+ throw err;
1127
1158
  }
1128
1159
  }
1129
- }
1130
1160
 
1131
- // Push secrets to Cloudflare
1132
- const secretsJsonPath = join(targetDir, ".secrets.json");
1133
- if (existsSync(secretsJsonPath)) {
1134
- reporter.start("Configuring secrets...");
1161
+ reporter.start("Deploying...");
1135
1162
 
1136
- const secretsResult = await $`wrangler secret bulk .secrets.json`
1137
- .cwd(targetDir)
1138
- .nothrow()
1139
- .quiet();
1163
+ const deployResult = await runWranglerDeploy(targetDir);
1140
1164
 
1141
- if (secretsResult.exitCode !== 0) {
1165
+ if (deployResult.exitCode !== 0) {
1142
1166
  reporter.stop();
1143
- reporter.warn("Failed to push secrets to Cloudflare");
1144
- reporter.info("Run manually: wrangler secret bulk .secrets.json");
1167
+ reporter.error("Deploy failed");
1168
+ throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
1169
+ exitCode: 0,
1170
+ stderr: deployResult.stderr.toString(),
1171
+ reported: hasReporter,
1172
+ });
1173
+ }
1174
+
1175
+ // Apply schema.sql after deploy
1176
+ if (await hasD1Config(targetDir)) {
1177
+ const dbName = await getD1DatabaseName(targetDir);
1178
+ if (dbName) {
1179
+ try {
1180
+ await applySchema(dbName, targetDir);
1181
+ } catch (err) {
1182
+ reporter.warn(`Schema application failed: ${err}`);
1183
+ reporter.info("Run manually: bun run db:migrate");
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ // Push secrets to Cloudflare
1189
+ const secretsJsonPath = join(targetDir, ".secrets.json");
1190
+ if (existsSync(secretsJsonPath)) {
1191
+ reporter.start("Configuring secrets...");
1192
+
1193
+ const secretsResult = await $`wrangler secret bulk .secrets.json`
1194
+ .cwd(targetDir)
1195
+ .nothrow()
1196
+ .quiet();
1197
+
1198
+ if (secretsResult.exitCode !== 0) {
1199
+ reporter.stop();
1200
+ reporter.warn("Failed to push secrets to Cloudflare");
1201
+ reporter.info("Run manually: wrangler secret bulk .secrets.json");
1202
+ } else {
1203
+ reporter.stop();
1204
+ reporter.success("Secrets configured");
1205
+ }
1206
+ }
1207
+
1208
+ // Parse URL from output
1209
+ const deployOutput = deployResult.stdout.toString();
1210
+ const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
1211
+ workerUrl = urlMatch ? urlMatch[0] : null;
1212
+
1213
+ reporter.stop();
1214
+ if (workerUrl) {
1215
+ reporter.success(`Live: ${workerUrl}`);
1145
1216
  } else {
1146
- reporter.stop();
1147
- reporter.success("Secrets configured");
1217
+ reporter.success("Deployed");
1148
1218
  }
1149
- }
1150
1219
 
1151
- // Parse URL from output
1152
- const deployOutput = deployResult.stdout.toString();
1153
- const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
1154
- workerUrl = urlMatch ? urlMatch[0] : null;
1220
+ // Generate BYO project ID and link locally
1221
+ const byoProjectId = generateByoProjectId();
1155
1222
 
1156
- reporter.stop();
1157
- if (workerUrl) {
1158
- reporter.success(`Live: ${workerUrl}`);
1159
- } else {
1160
- reporter.success("Deployed");
1223
+ // Link project locally and register path
1224
+ try {
1225
+ await linkProject(targetDir, byoProjectId, "byo");
1226
+ await writeTemplateMetadata(targetDir, templateOrigin);
1227
+ await registerPath(byoProjectId, targetDir);
1228
+ } catch (err) {
1229
+ debug("Failed to link BYO project:", err);
1230
+ }
1161
1231
  }
1232
+ timings.push({ label: "Deploy", duration: timerEnd("deploy") });
1233
+
1234
+ // Run post-deploy hooks (for both modes)
1235
+ if (template.hooks?.postDeploy?.length && workerUrl) {
1236
+ timerStart("post-deploy-hooks");
1237
+ const domain = workerUrl.replace(/^https?:\/\//, "");
1238
+ const hookResult = await runHook(
1239
+ template.hooks.postDeploy,
1240
+ {
1241
+ domain,
1242
+ url: workerUrl,
1243
+ projectName,
1244
+ projectDir: targetDir,
1245
+ },
1246
+ { interactive, output: reporter },
1247
+ );
1248
+ timings.push({ label: "Post-deploy hooks", duration: timerEnd("post-deploy-hooks") });
1162
1249
 
1163
- // Generate BYO project ID and link locally
1164
- const byoProjectId = generateByoProjectId();
1165
-
1166
- // Link project locally and register path
1167
- try {
1168
- await linkProject(targetDir, byoProjectId, "byo");
1169
- await writeTemplateMetadata(targetDir, templateOrigin);
1170
- await registerPath(byoProjectId, targetDir);
1171
- } catch (err) {
1172
- debug("Failed to link BYO project:", err);
1250
+ // Show final celebration if there were interactive prompts (URL might have scrolled away)
1251
+ if (hookResult.hadInteractiveActions && reporter.celebrate) {
1252
+ reporter.celebrate("You're live!", [domain]);
1253
+ }
1173
1254
  }
1174
- }
1175
1255
 
1176
- // Run post-deploy hooks (for both modes)
1177
- if (template.hooks?.postDeploy?.length && workerUrl) {
1178
- const domain = workerUrl.replace(/^https?:\/\//, "");
1179
- const hookResult = await runHook(
1180
- template.hooks.postDeploy,
1181
- {
1182
- domain,
1183
- url: workerUrl,
1184
- projectName,
1185
- projectDir: targetDir,
1186
- },
1187
- { interactive, output: reporter },
1188
- );
1256
+ // Print timing summary (only shown with --debug)
1257
+ printTimingSummary(timings);
1189
1258
 
1190
- // Show final celebration if there were interactive prompts (URL might have scrolled away)
1191
- if (hookResult.hadInteractiveActions && reporter.celebrate) {
1192
- reporter.celebrate("You're live!", [domain]);
1259
+ return {
1260
+ projectName,
1261
+ targetDir,
1262
+ workerUrl,
1263
+ deployMode,
1264
+ };
1265
+ } catch (error) {
1266
+ // Clean up directory if we created it
1267
+ if (directoryCreated && existsSync(targetDir)) {
1268
+ try {
1269
+ const { rm } = await import("node:fs/promises");
1270
+ await rm(targetDir, { recursive: true, force: true });
1271
+ } catch {
1272
+ // Ignore cleanup errors - user will see directory exists on retry
1273
+ }
1193
1274
  }
1275
+ throw error;
1194
1276
  }
1195
-
1196
- return {
1197
- projectName,
1198
- targetDir,
1199
- workerUrl,
1200
- deployMode,
1201
- };
1202
1277
  }
1203
1278
 
1204
1279
  // ============================================================================
@@ -1272,8 +1347,21 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1272
1347
  deployMode = link?.deploy_mode ?? "byo";
1273
1348
  }
1274
1349
 
1275
- // Validate mode availability
1350
+ // Ensure wrangler is installed (auto-install if needed)
1276
1351
  if (!dryRun) {
1352
+ let installSpinner: OperationSpinner | null = null;
1353
+ const wranglerReady = await ensureWranglerInstalled(() => {
1354
+ installSpinner = reporter.spinner("Installing dependencies...");
1355
+ });
1356
+ if (installSpinner) {
1357
+ if (wranglerReady) {
1358
+ (installSpinner as OperationSpinner).success("Dependencies installed");
1359
+ } else {
1360
+ (installSpinner as OperationSpinner).error("Failed to install dependencies");
1361
+ }
1362
+ }
1363
+
1364
+ // Validate mode availability
1277
1365
  const modeError = await validateModeAvailability(deployMode);
1278
1366
  if (modeError) {
1279
1367
  throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);