@getjack/jack 0.1.4 → 0.1.6

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 (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/login.ts +124 -1
  8. package/src/commands/logs.ts +8 -18
  9. package/src/commands/new.ts +7 -1
  10. package/src/commands/projects.ts +166 -105
  11. package/src/commands/secrets.ts +7 -6
  12. package/src/commands/services.ts +5 -4
  13. package/src/commands/tag.ts +282 -0
  14. package/src/commands/unlink.ts +30 -0
  15. package/src/index.ts +46 -1
  16. package/src/lib/auth/index.ts +2 -0
  17. package/src/lib/auth/store.ts +26 -2
  18. package/src/lib/binding-validator.ts +4 -13
  19. package/src/lib/build-helper.ts +93 -5
  20. package/src/lib/control-plane.ts +137 -0
  21. package/src/lib/deploy-mode.ts +1 -1
  22. package/src/lib/managed-deploy.ts +11 -1
  23. package/src/lib/managed-down.ts +7 -20
  24. package/src/lib/paths-index.test.ts +546 -0
  25. package/src/lib/paths-index.ts +310 -0
  26. package/src/lib/project-link.test.ts +459 -0
  27. package/src/lib/project-link.ts +279 -0
  28. package/src/lib/project-list.test.ts +581 -0
  29. package/src/lib/project-list.ts +449 -0
  30. package/src/lib/project-operations.ts +304 -183
  31. package/src/lib/project-resolver.ts +191 -211
  32. package/src/lib/tags.ts +389 -0
  33. package/src/lib/telemetry.ts +86 -157
  34. package/src/lib/zip-packager.ts +9 -0
  35. package/src/templates/index.ts +5 -3
  36. package/templates/api/.jack/template.json +4 -0
  37. package/templates/hello/.jack/template.json +4 -0
  38. package/templates/miniapp/.jack/template.json +4 -0
  39. package/templates/nextjs/.jack.json +28 -0
  40. package/templates/nextjs/app/globals.css +9 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -23,27 +23,33 @@ import {
23
23
  runAgentOneShot,
24
24
  validateAgentPaths,
25
25
  } from "./agents.ts";
26
- import { needsViteBuild, runViteBuild } from "./build-helper.ts";
26
+ import { ensureR2Buckets, needsOpenNextBuild, needsViteBuild, runOpenNextBuild, runViteBuild } from "./build-helper.ts";
27
27
  import { checkWorkerExists, getAccountId, listD1Databases } from "./cloudflare-api.ts";
28
28
  import { getSyncConfig } from "./config.ts";
29
+ import { deleteManagedProject } from "./control-plane.ts";
29
30
  import { debug, isDebug } from "./debug.ts";
30
31
  import { resolveDeployMode, validateModeAvailability } from "./deploy-mode.ts";
31
32
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
32
33
  import { JackError, JackErrorCode } from "./errors.ts";
33
34
  import { type HookOutput, runHook } from "./hooks.ts";
34
35
  import { loadTemplateKeywords, matchTemplateByIntent } from "./intent.ts";
35
- import { registerLocalPath } from "./local-paths.ts";
36
- import { createManagedProjectRemote, deployToManagedProject } from "./managed-deploy.ts";
36
+ import {
37
+ type ManagedCreateResult,
38
+ createManagedProjectRemote,
39
+ deployToManagedProject,
40
+ } from "./managed-deploy.ts";
37
41
  import { generateProjectName } from "./names.ts";
38
- import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
39
- import type { DeployMode, TemplateOrigin } from "./registry.ts";
42
+ import { getAllPaths, registerPath, unregisterPath } from "./paths-index.ts";
40
43
  import {
41
- getAllProjects,
42
- getProject,
43
- registerProject,
44
- removeProject,
45
- updateProject,
46
- } from "./registry.ts";
44
+ type DeployMode,
45
+ type TemplateMetadata as TemplateOrigin,
46
+ generateByoProjectId,
47
+ linkProject,
48
+ readProjectLink,
49
+ unlinkProject,
50
+ writeTemplateMetadata,
51
+ } from "./project-link.ts";
52
+ import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
47
53
  import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./schema.ts";
48
54
  import { getSavedSecrets, saveSecrets } from "./secrets.ts";
49
55
  import { getProjectNameFromDir, getRemoteManifest, syncToCloud } from "./storage/index.ts";
@@ -146,6 +152,90 @@ const noopReporter: OperationReporter = {
146
152
  box() {},
147
153
  };
148
154
 
155
+ /**
156
+ * Run bun install and managed project creation in parallel.
157
+ * Handles partial failures with cleanup.
158
+ */
159
+ async function runParallelSetup(
160
+ targetDir: string,
161
+ projectName: string,
162
+ options: {
163
+ template?: string;
164
+ usePrebuilt?: boolean;
165
+ },
166
+ ): Promise<{
167
+ installSuccess: boolean;
168
+ remoteResult: ManagedCreateResult;
169
+ }> {
170
+ const [installResult, remoteResult] = await Promise.allSettled([
171
+ // Install dependencies
172
+ (async () => {
173
+ const install = Bun.spawn(["bun", "install"], {
174
+ cwd: targetDir,
175
+ stdout: "ignore",
176
+ stderr: "ignore",
177
+ });
178
+ await install.exited;
179
+ if (install.exitCode !== 0) {
180
+ throw new Error("Dependency installation failed");
181
+ }
182
+ return true;
183
+ })(),
184
+
185
+ // Create managed project remote (no reporter to avoid spinner conflicts)
186
+ createManagedProjectRemote(projectName, undefined, {
187
+ template: options.template || "hello",
188
+ usePrebuilt: options.usePrebuilt ?? true,
189
+ }),
190
+ ]);
191
+
192
+ const installFailed = installResult.status === "rejected";
193
+ const remoteFailed = remoteResult.status === "rejected";
194
+
195
+ // Handle partial failures
196
+ if (installFailed && remoteResult.status === "fulfilled") {
197
+ // Install failed but remote succeeded - cleanup orphan cloud project
198
+ const remote = remoteResult.value;
199
+ try {
200
+ await deleteManagedProject(remote.projectId);
201
+ debug("Cleaned up orphan cloud project:", remote.projectId);
202
+ } catch (cleanupErr) {
203
+ debug("Failed to cleanup orphan cloud project:", cleanupErr);
204
+ }
205
+ throw new JackError(
206
+ JackErrorCode.BUILD_FAILED,
207
+ "Dependency installation failed",
208
+ "Run: bun install",
209
+ );
210
+ }
211
+
212
+ if (!installFailed && remoteResult.status === "rejected") {
213
+ // Remote failed but install succeeded - throw remote error
214
+ const error = remoteResult.reason;
215
+ throw error instanceof Error ? error : new Error(String(error));
216
+ }
217
+
218
+ if (installFailed && remoteFailed) {
219
+ // Both failed - prioritize install error (more actionable)
220
+ throw new JackError(
221
+ JackErrorCode.BUILD_FAILED,
222
+ "Dependency installation failed",
223
+ "Run: bun install",
224
+ );
225
+ }
226
+
227
+ // Both succeeded - TypeScript knows remoteResult.status === "fulfilled" here
228
+ if (remoteResult.status !== "fulfilled") {
229
+ // Should never happen, but satisfies TypeScript
230
+ throw new Error("Unexpected state: remote result not fulfilled");
231
+ }
232
+
233
+ return {
234
+ installSuccess: true,
235
+ remoteResult: remoteResult.value,
236
+ };
237
+ }
238
+
149
239
  const DEFAULT_D1_LIMIT = 10;
150
240
 
151
241
  async function preflightD1Capacity(
@@ -291,24 +381,9 @@ export async function createProject(
291
381
  const choice = await promptSelect(["Link existing project", "Choose different name"]);
292
382
 
293
383
  if (choice === 0) {
294
- // User chose to link - cache in registry and proceed
295
- await registerProject(projectName, {
296
- workerUrl: existingProject.url || null,
297
- createdAt: existingProject.createdAt,
298
- lastDeployed: existingProject.updatedAt || null,
299
- status: existingProject.status === "live" ? "live" : "build_failed",
300
- deploy_mode: "managed",
301
- remote: existingProject.remote
302
- ? {
303
- project_id: existingProject.remote.projectId,
304
- project_slug: existingProject.slug,
305
- org_id: existingProject.remote.orgId,
306
- runjack_url:
307
- existingProject.url || `https://${existingProject.slug}.runjack.xyz`,
308
- }
309
- : undefined,
310
- });
311
- reporter.success(`Linked to existing project: ${existingProject.url || projectName}`);
384
+ // User chose to link - proceed with project creation
385
+ // The project will be linked locally when files are created
386
+ reporter.success(`Linking to existing project: ${existingProject.url || projectName}`);
312
387
  // Continue with project creation - user wants to link
313
388
  } else {
314
389
  // User chose different name
@@ -570,15 +645,8 @@ export async function createProject(
570
645
  const validation = await validateAgentPaths();
571
646
 
572
647
  if (validation.invalid.length > 0) {
573
- reporter.stop();
574
- reporter.warn("Some agent paths no longer exist:");
575
- for (const { id, path } of validation.invalid) {
576
- reporter.info(` ${id}: ${path}`);
577
- }
578
- reporter.info("Run: jack agents scan");
579
- reporter.start("Creating project...");
580
-
581
- // Filter out invalid agents
648
+ // Silently filter out agents with missing paths
649
+ // User can run 'jack agents scan' to see/fix agent config
582
650
  activeAgents = activeAgents.filter(
583
651
  ({ id }) => !validation.invalid.some((inv) => inv.id === id),
584
652
  );
@@ -596,30 +664,55 @@ export async function createProject(
596
664
  reporter.stop();
597
665
  reporter.success(`Created ${projectName}/`);
598
666
 
599
- // Auto-install dependencies
600
- reporter.start("Installing dependencies...");
667
+ // Parallel setup for managed mode: install + remote creation
668
+ let remoteResult: ManagedCreateResult | undefined;
601
669
 
602
- const install = Bun.spawn(["bun", "install"], {
603
- cwd: targetDir,
604
- stdout: "ignore",
605
- stderr: "ignore",
606
- });
607
- await install.exited;
670
+ if (deployMode === "managed") {
671
+ // Run install and remote creation in parallel
672
+ reporter.start("Setting up project...");
673
+
674
+ try {
675
+ const result = await runParallelSetup(targetDir, projectName, {
676
+ template: resolvedTemplate || "hello",
677
+ usePrebuilt: true,
678
+ });
679
+ remoteResult = result.remoteResult;
680
+ reporter.stop();
681
+ reporter.success("Project setup complete");
682
+ } catch (err) {
683
+ reporter.stop();
684
+ if (err instanceof JackError) {
685
+ reporter.warn(err.suggestion ?? err.message);
686
+ throw err;
687
+ }
688
+ throw err;
689
+ }
690
+ } else {
691
+ // BYO mode: just install dependencies (unchanged from current)
692
+ reporter.start("Installing dependencies...");
693
+
694
+ const install = Bun.spawn(["bun", "install"], {
695
+ cwd: targetDir,
696
+ stdout: "ignore",
697
+ stderr: "ignore",
698
+ });
699
+ await install.exited;
700
+
701
+ if (install.exitCode !== 0) {
702
+ reporter.stop();
703
+ reporter.warn("Failed to install dependencies, run: bun install");
704
+ throw new JackError(
705
+ JackErrorCode.BUILD_FAILED,
706
+ "Dependency installation failed",
707
+ "Run: bun install",
708
+ { exitCode: 0, reported: hasReporter },
709
+ );
710
+ }
608
711
 
609
- if (install.exitCode !== 0) {
610
712
  reporter.stop();
611
- reporter.warn("Failed to install dependencies, run: bun install");
612
- throw new JackError(
613
- JackErrorCode.BUILD_FAILED,
614
- "Dependency installation failed",
615
- "Run: bun install",
616
- { exitCode: 0, reported: hasReporter },
617
- );
713
+ reporter.success("Dependencies installed");
618
714
  }
619
715
 
620
- reporter.stop();
621
- reporter.success("Dependencies installed");
622
-
623
716
  // Run pre-deploy hooks
624
717
  if (template.hooks?.preDeploy?.length) {
625
718
  const hookContext = { projectName, projectDir: targetDir };
@@ -685,30 +778,22 @@ export async function createProject(
685
778
 
686
779
  // Deploy based on mode
687
780
  if (deployMode === "managed") {
688
- // Managed mode: create project and deploy via jack cloud
689
- const remoteResult = await createManagedProjectRemote(projectName, reporter, {
690
- template: resolvedTemplate || "hello",
691
- usePrebuilt: true,
692
- });
781
+ // Managed mode: remote was already created in parallel setup
782
+ if (!remoteResult) {
783
+ throw new JackError(
784
+ JackErrorCode.VALIDATION_ERROR,
785
+ "Managed project was not created",
786
+ "This is an internal error - please report it",
787
+ );
788
+ }
693
789
 
694
- // Register project as soon as remote is created
790
+ // Link project locally and register path
695
791
  try {
696
- await registerProject(projectName, {
697
- workerUrl: remoteResult.runjackUrl,
698
- createdAt: new Date().toISOString(),
699
- lastDeployed: remoteResult.status === "live" ? new Date().toISOString() : null,
700
- status: remoteResult.status === "live" ? "live" : "created",
701
- template: templateOrigin,
702
- deploy_mode: "managed",
703
- remote: {
704
- project_id: remoteResult.projectId,
705
- project_slug: remoteResult.projectSlug,
706
- org_id: remoteResult.orgId,
707
- runjack_url: remoteResult.runjackUrl,
708
- },
709
- });
792
+ await linkProject(targetDir, remoteResult.projectId, "managed");
793
+ await writeTemplateMetadata(targetDir, templateOrigin);
794
+ await registerPath(remoteResult.projectId, targetDir);
710
795
  } catch (err) {
711
- debug("Failed to register managed project:", err);
796
+ debug("Failed to link managed project:", err);
712
797
  }
713
798
 
714
799
  // Check if prebuilt deployment succeeded
@@ -725,38 +810,26 @@ export async function createProject(
725
810
  reporter.info("Pre-built not available, building fresh...");
726
811
  }
727
812
 
728
- try {
729
- await deployToManagedProject(remoteResult.projectId, targetDir, reporter);
730
- } catch (err) {
731
- try {
732
- await updateProject(projectName, {
733
- status: "build_failed",
734
- workerUrl: remoteResult.runjackUrl,
735
- });
736
- } catch (updateErr) {
737
- debug("Failed to update managed project status:", updateErr);
738
- }
739
- throw err;
740
- }
813
+ await deployToManagedProject(remoteResult.projectId, targetDir, reporter);
741
814
  workerUrl = remoteResult.runjackUrl;
742
815
  reporter.success(`Created: ${workerUrl}`);
743
-
744
- // Update project status to live after successful fresh build
745
- try {
746
- await updateProject(projectName, {
747
- lastDeployed: new Date().toISOString(),
748
- status: "live",
749
- });
750
- } catch (err) {
751
- // Log but don't fail - registry is convenience, not critical path
752
- debug("Failed to update managed project status:", err);
753
- }
754
816
  }
755
817
  } else {
756
818
  // BYO mode: deploy via wrangler
757
819
 
758
- // Build first if needed (wrangler needs dist/ for assets)
759
- if (await needsViteBuild(targetDir)) {
820
+ // Build first if needed (wrangler needs built assets)
821
+ if (await needsOpenNextBuild(targetDir)) {
822
+ reporter.start("Building...");
823
+ try {
824
+ await runOpenNextBuild(targetDir);
825
+ reporter.stop();
826
+ reporter.success("Built");
827
+ } catch (err) {
828
+ reporter.stop();
829
+ reporter.error("Build failed");
830
+ throw err;
831
+ }
832
+ } else if (await needsViteBuild(targetDir)) {
760
833
  reporter.start("Building...");
761
834
  try {
762
835
  await runViteBuild(targetDir);
@@ -828,23 +901,16 @@ export async function createProject(
828
901
  reporter.success("Deployed");
829
902
  }
830
903
 
831
- // Register project with BYO mode
904
+ // Generate BYO project ID and link locally
905
+ const byoProjectId = generateByoProjectId();
906
+
907
+ // Link project locally and register path
832
908
  try {
833
- const accountId = await getAccountId();
834
-
835
- await registerProject(projectName, {
836
- workerUrl,
837
- createdAt: new Date().toISOString(),
838
- lastDeployed: workerUrl ? new Date().toISOString() : null,
839
- cloudflare: {
840
- accountId,
841
- workerId: projectName,
842
- },
843
- template: templateOrigin,
844
- deploy_mode: "byo",
845
- });
846
- } catch {
847
- // Don't fail the creation if registry update fails
909
+ await linkProject(targetDir, byoProjectId, "byo");
910
+ await writeTemplateMetadata(targetDir, templateOrigin);
911
+ await registerPath(byoProjectId, targetDir);
912
+ } catch (err) {
913
+ debug("Failed to link BYO project:", err);
848
914
  }
849
915
  }
850
916
 
@@ -863,13 +929,6 @@ export async function createProject(
863
929
  );
864
930
  }
865
931
 
866
- // Auto-register local path for project discovery
867
- try {
868
- await registerLocalPath(projectName, targetDir);
869
- } catch {
870
- // Silent fail - registration is best-effort
871
- }
872
-
873
932
  return {
874
933
  projectName,
875
934
  targetDir,
@@ -926,8 +985,8 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
926
985
  // Get project name from directory
927
986
  const projectName = await getProjectNameFromDir(projectPath);
928
987
 
929
- // Get project from registry to check stored mode
930
- const project = await getProject(projectName);
988
+ // Read local project link for stored mode and project ID
989
+ const link = await readProjectLink(projectPath);
931
990
 
932
991
  // Determine effective mode: explicit flag > stored mode > default BYO
933
992
  let deployMode: DeployMode;
@@ -936,7 +995,7 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
936
995
  } else if (options.byo) {
937
996
  deployMode = "byo";
938
997
  } else {
939
- deployMode = project?.deploy_mode ?? "byo";
998
+ deployMode = link?.deploy_mode ?? "byo";
940
999
  }
941
1000
 
942
1001
  // Validate mode availability
@@ -951,7 +1010,7 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
951
1010
  // Deploy based on mode
952
1011
  if (deployMode === "managed") {
953
1012
  // Managed mode: deploy via jack cloud
954
- if (!project?.remote?.project_id) {
1013
+ if (!link?.project_id || link.deploy_mode !== "managed") {
955
1014
  throw new JackError(
956
1015
  JackErrorCode.VALIDATION_ERROR,
957
1016
  "Project not linked to jack cloud",
@@ -960,19 +1019,24 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
960
1019
  }
961
1020
 
962
1021
  // deployToManagedProject now handles both template and code deploy
963
- const result = await deployToManagedProject(project.remote.project_id, projectPath, reporter);
964
-
965
- workerUrl = project.remote.runjack_url;
1022
+ await deployToManagedProject(link.project_id, projectPath, reporter);
966
1023
 
967
- // Update lastDeployed in registry (will be persisted below)
968
- if (project) {
969
- project.lastDeployed = new Date().toISOString();
970
- }
1024
+ // Get the URL from the resolver or construct it
1025
+ workerUrl = `https://${projectName}.runjack.xyz`;
971
1026
  } else {
972
1027
  // BYO mode: deploy via wrangler
973
1028
 
974
- // Build first if needed (wrangler needs dist/ for assets)
975
- if (await needsViteBuild(projectPath)) {
1029
+ // Build first if needed (wrangler needs built assets)
1030
+ if (await needsOpenNextBuild(projectPath)) {
1031
+ const buildSpin = reporter.spinner("Building...");
1032
+ try {
1033
+ await runOpenNextBuild(projectPath);
1034
+ buildSpin.success("Built");
1035
+ } catch (err) {
1036
+ buildSpin.error("Build failed");
1037
+ throw err;
1038
+ }
1039
+ } else if (await needsViteBuild(projectPath)) {
976
1040
  const buildSpin = reporter.spinner("Building...");
977
1041
  try {
978
1042
  await runViteBuild(projectPath);
@@ -983,6 +1047,17 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
983
1047
  }
984
1048
  }
985
1049
 
1050
+ // Ensure R2 buckets exist before deploying (omakase - auto-provision)
1051
+ try {
1052
+ const buckets = await ensureR2Buckets(projectPath);
1053
+ if (buckets.length > 0) {
1054
+ reporter.info(`R2 buckets ready: ${buckets.join(", ")}`);
1055
+ }
1056
+ } catch (err) {
1057
+ // Non-fatal: let wrangler deploy fail with a clearer error if bucket is missing
1058
+ debug("R2 preflight failed:", err);
1059
+ }
1060
+
986
1061
  const spin = reporter.spinner("Deploying...");
987
1062
  const result = await $`wrangler deploy`.cwd(projectPath).nothrow().quiet();
988
1063
 
@@ -1021,16 +1096,6 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1021
1096
  }
1022
1097
  }
1023
1098
 
1024
- // Update registry
1025
- try {
1026
- await registerProject(projectName, {
1027
- workerUrl,
1028
- lastDeployed: new Date().toISOString(),
1029
- });
1030
- } catch {
1031
- // Don't fail the deploy if registry update fails
1032
- }
1033
-
1034
1099
  if (includeSecrets && interactive) {
1035
1100
  const detected = await detectSecrets(projectPath);
1036
1101
  const newSecrets = await filterNewSecrets(detected);
@@ -1063,9 +1128,24 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1063
1128
  }
1064
1129
  }
1065
1130
 
1066
- // Auto-register local path for project discovery
1131
+ // Ensure project is linked locally for discovery
1067
1132
  try {
1068
- await registerLocalPath(projectName, projectPath);
1133
+ const existingLink = await readProjectLink(projectPath);
1134
+ if (!existingLink) {
1135
+ // Not linked yet - create link
1136
+ if (deployMode === "managed" && link?.project_id) {
1137
+ await linkProject(projectPath, link.project_id, "managed");
1138
+ await registerPath(link.project_id, projectPath);
1139
+ } else {
1140
+ // BYO mode - generate new ID
1141
+ const byoProjectId = generateByoProjectId();
1142
+ await linkProject(projectPath, byoProjectId, "byo");
1143
+ await registerPath(byoProjectId, projectPath);
1144
+ }
1145
+ } else {
1146
+ // Already linked - just ensure path is registered
1147
+ await registerPath(existingLink.project_id, projectPath);
1148
+ }
1069
1149
  } catch {
1070
1150
  // Silent fail - registration is best-effort
1071
1151
  }
@@ -1108,11 +1188,8 @@ export async function getProjectStatus(
1108
1188
  }
1109
1189
  }
1110
1190
 
1111
- const project = await getProject(projectName);
1112
-
1113
- if (!project) {
1114
- return null;
1115
- }
1191
+ // Read local project link
1192
+ const link = await readProjectLink(resolvedPath);
1116
1193
 
1117
1194
  // Check if local project exists at the resolved path
1118
1195
  const hasWranglerConfig =
@@ -1122,6 +1199,11 @@ export async function getProjectStatus(
1122
1199
  const localExists = hasWranglerConfig;
1123
1200
  const localPath = localExists ? resolvedPath : null;
1124
1201
 
1202
+ // If no link and no local project, return null
1203
+ if (!link && !localExists) {
1204
+ return null;
1205
+ }
1206
+
1125
1207
  // Check actual deployment status
1126
1208
  const [workerExists, manifest] = await Promise.all([
1127
1209
  checkWorkerExists(projectName),
@@ -1131,13 +1213,19 @@ export async function getProjectStatus(
1131
1213
  const backupFiles = manifest ? manifest.files.length : null;
1132
1214
  const backupLastSync = manifest ? manifest.lastSync : null;
1133
1215
 
1216
+ // Determine URL based on mode
1217
+ let workerUrl: string | null = null;
1218
+ if (link?.deploy_mode === "managed") {
1219
+ workerUrl = `https://${projectName}.runjack.xyz`;
1220
+ }
1221
+
1134
1222
  // Get database name on-demand
1135
1223
  let dbName: string | null = null;
1136
- if (project.deploy_mode === "managed" && project.remote?.project_id) {
1224
+ if (link?.deploy_mode === "managed") {
1137
1225
  // For managed projects, fetch from control plane
1138
1226
  try {
1139
1227
  const { fetchProjectResources } = await import("./control-plane.ts");
1140
- const resources = await fetchProjectResources(project.remote.project_id);
1228
+ const resources = await fetchProjectResources(link.project_id);
1141
1229
  const d1 = resources.find((r) => r.resource_type === "d1");
1142
1230
  dbName = d1?.resource_name || null;
1143
1231
  } catch {
@@ -1157,16 +1245,16 @@ export async function getProjectStatus(
1157
1245
  return {
1158
1246
  name: projectName,
1159
1247
  localPath,
1160
- workerUrl: project.workerUrl,
1161
- lastDeployed: project.lastDeployed,
1162
- createdAt: project.createdAt,
1163
- accountId: project.cloudflare?.accountId ?? null,
1164
- workerId: project.cloudflare?.workerId ?? null,
1248
+ workerUrl,
1249
+ lastDeployed: link?.linked_at ?? null,
1250
+ createdAt: link?.linked_at ?? null,
1251
+ accountId: null, // No longer stored in registry
1252
+ workerId: projectName,
1165
1253
  dbName,
1166
- deployed: workerExists || !!project.workerUrl,
1254
+ deployed: workerExists || !!workerUrl,
1167
1255
  local: localExists,
1168
1256
  backedUp,
1169
- missing: false, // No longer tracking local paths in registry
1257
+ missing: false,
1170
1258
  backupFiles,
1171
1259
  backupLastSync,
1172
1260
  };
@@ -1177,44 +1265,77 @@ export async function getProjectStatus(
1177
1265
  // ============================================================================
1178
1266
 
1179
1267
  /**
1180
- * Scan registry for stale projects.
1181
- * Checks for projects with worker URLs that no longer have deployed workers.
1268
+ * Scan for stale project paths.
1269
+ * Checks for paths in the index that no longer exist on disk or don't have valid links.
1182
1270
  * Returns total project count and stale entries with reasons.
1183
1271
  */
1184
1272
  export async function scanStaleProjects(): Promise<StaleProjectScan> {
1185
- const projects = await getAllProjects();
1186
- const projectNames = Object.keys(projects);
1273
+ const allPaths = await getAllPaths();
1274
+ const projectIds = Object.keys(allPaths);
1187
1275
  const stale: StaleProject[] = [];
1276
+ let totalPaths = 0;
1277
+
1278
+ for (const projectId of projectIds) {
1279
+ const paths = allPaths[projectId] || [];
1280
+ totalPaths += paths.length;
1281
+
1282
+ for (const projectPath of paths) {
1283
+ // Check if path exists and has valid link
1284
+ const hasWranglerConfig =
1285
+ existsSync(join(projectPath, "wrangler.jsonc")) ||
1286
+ existsSync(join(projectPath, "wrangler.toml")) ||
1287
+ existsSync(join(projectPath, "wrangler.json"));
1288
+
1289
+ if (!hasWranglerConfig) {
1290
+ // Try to get project name from the path
1291
+ let name = projectPath.split("/").pop() || projectId;
1292
+ try {
1293
+ name = await getProjectNameFromDir(projectPath);
1294
+ } catch {
1295
+ // Use path basename as fallback
1296
+ }
1188
1297
 
1189
- for (const name of projectNames) {
1190
- const project = projects[name];
1191
- if (!project) continue;
1192
-
1193
- // Check if worker URL is set but worker doesn't exist
1194
- if (project.workerUrl) {
1195
- const workerExists = await checkWorkerExists(name);
1196
- if (!workerExists) {
1197
1298
  stale.push({
1198
1299
  name,
1199
1300
  reason: "worker not deployed",
1200
- workerUrl: project.workerUrl,
1301
+ workerUrl: null,
1201
1302
  });
1202
1303
  }
1203
1304
  }
1204
1305
  }
1205
1306
 
1206
- return { total: projectNames.length, stale };
1307
+ return { total: totalPaths, stale };
1207
1308
  }
1208
1309
 
1209
1310
  /**
1210
- * Remove stale registry entries by name
1311
+ * Remove stale project entries by path
1312
+ * Unlinks and unregisters projects.
1211
1313
  * Returns the number of entries removed.
1212
1314
  */
1213
1315
  export async function cleanupStaleProjects(names: string[]): Promise<number> {
1214
1316
  let removed = 0;
1317
+
1318
+ // Get all paths to find matching projects
1319
+ const allPaths = await getAllPaths();
1320
+
1215
1321
  for (const name of names) {
1216
- await removeProject(name);
1217
- removed += 1;
1322
+ // Find project ID by checking each path
1323
+ for (const [projectId, paths] of Object.entries(allPaths)) {
1324
+ for (const projectPath of paths || []) {
1325
+ const pathName = projectPath.split("/").pop();
1326
+ if (pathName === name) {
1327
+ // Unlink and unregister
1328
+ try {
1329
+ await unlinkProject(projectPath);
1330
+ } catch {
1331
+ // Path may not exist
1332
+ }
1333
+ await unregisterPath(projectId, projectPath);
1334
+ removed += 1;
1335
+ }
1336
+ }
1337
+ }
1218
1338
  }
1339
+
1219
1340
  return removed;
1220
1341
  }