@getjack/jack 0.1.16 → 0.1.19

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.
@@ -14,7 +14,7 @@ import {
14
14
  renderTemplate,
15
15
  resolveTemplateWithOrigin,
16
16
  } from "../templates/index.ts";
17
- import type { Template } from "../templates/types.ts";
17
+ import type { EnvVar, Template } from "../templates/types.ts";
18
18
  import { generateAgentFiles } from "./agent-files.ts";
19
19
  import {
20
20
  getActiveAgents,
@@ -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
  // ============================================================================
@@ -168,6 +167,129 @@ const noopReporter: OperationReporter = {
168
167
  box() {},
169
168
  };
170
169
 
170
+ /**
171
+ * Check if an environment variable already exists in a .env file
172
+ * Returns the existing value if found, null otherwise
173
+ */
174
+ async function checkEnvVarExists(envPath: string, key: string): Promise<string | null> {
175
+ if (!existsSync(envPath)) {
176
+ return null;
177
+ }
178
+
179
+ const content = await Bun.file(envPath).text();
180
+ for (const line of content.split("\n")) {
181
+ const trimmed = line.trim();
182
+ if (!trimmed || trimmed.startsWith("#")) {
183
+ continue;
184
+ }
185
+
186
+ const eqIndex = trimmed.indexOf("=");
187
+ if (eqIndex === -1) {
188
+ continue;
189
+ }
190
+
191
+ const lineKey = trimmed.slice(0, eqIndex).trim();
192
+ if (lineKey === key) {
193
+ let value = trimmed.slice(eqIndex + 1).trim();
194
+ // Remove surrounding quotes
195
+ if (
196
+ (value.startsWith('"') && value.endsWith('"')) ||
197
+ (value.startsWith("'") && value.endsWith("'"))
198
+ ) {
199
+ value = value.slice(1, -1);
200
+ }
201
+ return value;
202
+ }
203
+ }
204
+
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Prompt for environment variables defined in a template
210
+ * Returns a record of env var name -> value for vars that were provided
211
+ */
212
+ async function promptEnvVars(
213
+ envVars: EnvVar[],
214
+ targetDir: string,
215
+ reporter: OperationReporter,
216
+ interactive: boolean,
217
+ ): Promise<Record<string, string>> {
218
+ const result: Record<string, string> = {};
219
+ const envPath = join(targetDir, ".env");
220
+
221
+ for (const envVar of envVars) {
222
+ // Check if already exists in .env
223
+ const existingValue = await checkEnvVarExists(envPath, envVar.name);
224
+ if (existingValue) {
225
+ reporter.stop();
226
+ reporter.success(`${envVar.name}: already configured`);
227
+ reporter.start("Creating project...");
228
+ result[envVar.name] = existingValue;
229
+ continue;
230
+ }
231
+
232
+ if (!interactive) {
233
+ // Non-interactive mode: use default if available, otherwise warn
234
+ if (envVar.defaultValue !== undefined) {
235
+ result[envVar.name] = envVar.defaultValue;
236
+ reporter.stop();
237
+ reporter.info(`${envVar.name}: using default value`);
238
+ reporter.start("Creating project...");
239
+ } else if (envVar.required !== false) {
240
+ reporter.stop();
241
+ reporter.warn(`${envVar.name}: required but not set (no default available)`);
242
+ reporter.start("Creating project...");
243
+ }
244
+ continue;
245
+ }
246
+
247
+ // Interactive mode: prompt user
248
+ reporter.stop();
249
+ const { isCancel, text } = await import("@clack/prompts");
250
+
251
+ console.error("");
252
+ console.error(` ${envVar.description}`);
253
+ if (envVar.setupUrl) {
254
+ console.error(` Get it at: ${envVar.setupUrl}`);
255
+ }
256
+ if (envVar.example) {
257
+ console.error(` Example: ${envVar.example}`);
258
+ }
259
+ console.error("");
260
+
261
+ const value = await text({
262
+ message: `${envVar.name}:`,
263
+ defaultValue: envVar.defaultValue,
264
+ placeholder: envVar.defaultValue ?? (envVar.example ? `e.g. ${envVar.example}` : undefined),
265
+ });
266
+
267
+ if (isCancel(value)) {
268
+ // User cancelled - skip this var
269
+ if (envVar.required !== false) {
270
+ reporter.warn(`Skipped required env var: ${envVar.name}`);
271
+ }
272
+ reporter.start("Creating project...");
273
+ continue;
274
+ }
275
+
276
+ const trimmedValue = value.trim();
277
+ if (trimmedValue) {
278
+ result[envVar.name] = trimmedValue;
279
+ reporter.success(`Set ${envVar.name}`);
280
+ } else if (envVar.defaultValue !== undefined) {
281
+ result[envVar.name] = envVar.defaultValue;
282
+ reporter.info(`${envVar.name}: using default value`);
283
+ } else if (envVar.required !== false) {
284
+ reporter.warn(`Skipped required env var: ${envVar.name}`);
285
+ }
286
+
287
+ reporter.start("Creating project...");
288
+ }
289
+
290
+ return result;
291
+ }
292
+
171
293
  /**
172
294
  * Get the wrangler config file path for a project
173
295
  * Returns the first found: wrangler.jsonc, wrangler.toml, wrangler.json
@@ -198,9 +320,36 @@ async function runWranglerDeploy(
198
320
  return await $`wrangler deploy ${configArg} ${dryRunArgs}`.cwd(projectPath).nothrow().quiet();
199
321
  }
200
322
 
323
+ /**
324
+ * Ensure Cloudflare authentication is in place before BYO operations.
325
+ * Checks wrangler auth and CLOUDFLARE_API_TOKEN env var.
326
+ */
327
+ async function ensureCloudflareAuth(
328
+ interactive: boolean,
329
+ reporter: OperationReporter,
330
+ ): Promise<void> {
331
+ const { isAuthenticated, ensureAuth } = await import("./wrangler.ts");
332
+ const cfAuthenticated = await isAuthenticated();
333
+ const hasApiToken = Boolean(process.env.CLOUDFLARE_API_TOKEN);
334
+
335
+ if (!cfAuthenticated && !hasApiToken) {
336
+ if (interactive) {
337
+ reporter.info("Cloudflare authentication required");
338
+ await ensureAuth();
339
+ } else {
340
+ throw new JackError(
341
+ JackErrorCode.AUTH_FAILED,
342
+ "Not authenticated with Cloudflare",
343
+ "Run: wrangler login\nOr set CLOUDFLARE_API_TOKEN environment variable",
344
+ );
345
+ }
346
+ }
347
+ }
348
+
201
349
  /**
202
350
  * Run bun install and managed project creation in parallel.
203
351
  * Handles partial failures with cleanup.
352
+ * Optionally reports URL early via onRemoteReady callback.
204
353
  */
205
354
  async function runParallelSetup(
206
355
  targetDir: string,
@@ -208,32 +357,41 @@ async function runParallelSetup(
208
357
  options: {
209
358
  template?: string;
210
359
  usePrebuilt?: boolean;
360
+ onRemoteReady?: (result: ManagedCreateResult) => void;
211
361
  },
212
362
  ): Promise<{
213
363
  installSuccess: boolean;
214
364
  remoteResult: ManagedCreateResult;
215
365
  }> {
216
- const [installResult, remoteResult] = await Promise.allSettled([
217
- // Install dependencies
218
- (async () => {
219
- const install = Bun.spawn(["bun", "install"], {
220
- cwd: targetDir,
221
- stdout: "ignore",
222
- stderr: "ignore",
223
- });
224
- await install.exited;
225
- if (install.exitCode !== 0) {
226
- throw new Error("Dependency installation failed");
366
+ // Start both operations
367
+ const installPromise = (async () => {
368
+ const install = Bun.spawn(["bun", "install", "--prefer-offline"], {
369
+ cwd: targetDir,
370
+ stdout: "ignore",
371
+ stderr: "ignore",
372
+ });
373
+ await install.exited;
374
+ if (install.exitCode !== 0) {
375
+ throw new Error("Dependency installation failed");
376
+ }
377
+ return true;
378
+ })();
379
+
380
+ const remotePromise = createManagedProjectRemote(projectName, undefined, {
381
+ template: options.template || "hello",
382
+ usePrebuilt: options.usePrebuilt ?? true,
383
+ });
384
+
385
+ // Report URL as soon as remote is ready (don't wait for install)
386
+ remotePromise
387
+ .then((result) => {
388
+ if (result.status === "live" && options.onRemoteReady) {
389
+ options.onRemoteReady(result);
227
390
  }
228
- return true;
229
- })(),
230
-
231
- // Create managed project remote (no reporter to avoid spinner conflicts)
232
- createManagedProjectRemote(projectName, undefined, {
233
- template: options.template || "hello",
234
- usePrebuilt: options.usePrebuilt ?? true,
235
- }),
236
- ]);
391
+ })
392
+ .catch(() => {}); // Errors handled below in allSettled
393
+
394
+ const [installResult, remoteResult] = await Promise.allSettled([installPromise, remotePromise]);
237
395
 
238
396
  const installFailed = installResult.status === "rejected";
239
397
  const remoteFailed = remoteResult.status === "rejected";
@@ -814,6 +972,25 @@ export async function createProject(
814
972
  const rendered = renderTemplate(template, { name: projectName });
815
973
  timings.push({ label: "Template load", duration: timerEnd("template-load") });
816
974
 
975
+ // Run preCreate hooks (for interactive secret collection, auto-generation, etc.)
976
+ if (template.hooks?.preCreate?.length) {
977
+ timerStart("pre-create-hooks");
978
+ const hookContext = { projectName, projectDir: targetDir };
979
+ const hookResult = await runHook(template.hooks.preCreate, hookContext, {
980
+ interactive,
981
+ output: reporter,
982
+ });
983
+ timings.push({ label: "Pre-create hooks", duration: timerEnd("pre-create-hooks") });
984
+
985
+ if (!hookResult.success) {
986
+ throw new JackError(
987
+ JackErrorCode.VALIDATION_ERROR,
988
+ "Project setup incomplete",
989
+ "Missing required configuration",
990
+ );
991
+ }
992
+ }
993
+
817
994
  // Handle template-specific secrets
818
995
  const secretsToUse: Record<string, string> = {};
819
996
  if (template.secrets?.length) {
@@ -859,47 +1036,46 @@ export async function createProject(
859
1036
  continue;
860
1037
  }
861
1038
 
862
- // Prompt user
1039
+ // Prompt user - single text input, empty/Esc to skip
863
1040
  reporter.stop();
864
- const { isCancel, select, text } = await import("@clack/prompts");
1041
+ const { isCancel, text } = await import("@clack/prompts");
865
1042
  console.error("");
866
1043
  console.error(` ${optionalSecret.description}`);
867
1044
  if (optionalSecret.setupUrl) {
868
- console.error(` Setup: ${optionalSecret.setupUrl}`);
1045
+ console.error(` Get it at: \x1b[36m${optionalSecret.setupUrl}\x1b[0m`);
869
1046
  }
870
1047
  console.error("");
871
1048
 
872
- const choice = await select({
873
- message: `Add ${optionalSecret.name}?`,
874
- options: [
875
- { label: "Yes", value: "yes" },
876
- { label: "Skip", value: "skip" },
877
- ],
1049
+ const value = await text({
1050
+ message: `${optionalSecret.name}:`,
1051
+ placeholder: "paste value or press Esc to skip",
878
1052
  });
879
1053
 
880
- if (!isCancel(choice) && choice === "yes") {
881
- const value = await text({
882
- message: `Enter ${optionalSecret.name}:`,
883
- });
884
-
885
- if (!isCancel(value) && value.trim()) {
886
- secretsToUse[optionalSecret.name] = value.trim();
887
- // Save to global secrets for reuse
888
- await saveSecrets([
889
- {
890
- key: optionalSecret.name,
891
- value: value.trim(),
892
- source: "optional-template",
893
- },
894
- ]);
895
- reporter.success(`Saved ${optionalSecret.name}`);
896
- }
1054
+ if (!isCancel(value) && value.trim()) {
1055
+ secretsToUse[optionalSecret.name] = value.trim();
1056
+ // Save to global secrets for reuse
1057
+ await saveSecrets([
1058
+ {
1059
+ key: optionalSecret.name,
1060
+ value: value.trim(),
1061
+ source: "optional-template",
1062
+ },
1063
+ ]);
1064
+ reporter.success(`Saved ${optionalSecret.name}`);
1065
+ } else {
1066
+ reporter.info(`Skipped ${optionalSecret.name}`);
897
1067
  }
898
1068
 
899
1069
  reporter.start("Creating project...");
900
1070
  }
901
1071
  }
902
1072
 
1073
+ // Handle environment variables (non-secret configuration)
1074
+ let envVarsToUse: Record<string, string> = {};
1075
+ if (template.envVars?.length) {
1076
+ envVarsToUse = await promptEnvVars(template.envVars, targetDir, reporter, interactive);
1077
+ }
1078
+
903
1079
  // Track if we created the directory (for cleanup on failure)
904
1080
  let directoryCreated = false;
905
1081
 
@@ -918,13 +1094,24 @@ export async function createProject(
918
1094
  }
919
1095
  reporter.start("Creating project...");
920
1096
 
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);
1097
+ // Write secrets and env vars files
1098
+ // - Secrets go to: .env, .dev.vars, .secrets.json (for wrangler bulk upload)
1099
+ // - Env vars go to: .env, .dev.vars only (not secrets.json - they're not secrets)
1100
+ const hasSecrets = Object.keys(secretsToUse).length > 0;
1101
+ const hasEnvVars = Object.keys(envVarsToUse).length > 0;
1102
+
1103
+ if (hasSecrets || hasEnvVars) {
1104
+ // Combine secrets and env vars for .env and .dev.vars
1105
+ const allEnvVars = { ...secretsToUse, ...envVarsToUse };
1106
+ const envContent = generateEnvFile(allEnvVars);
925
1107
  await Bun.write(join(targetDir, ".env"), envContent);
926
1108
  await Bun.write(join(targetDir, ".dev.vars"), envContent);
927
- await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
1109
+
1110
+ // Only write secrets to .secrets.json (for wrangler secret bulk)
1111
+ if (hasSecrets) {
1112
+ const jsonContent = generateSecretsJson(secretsToUse);
1113
+ await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
1114
+ }
928
1115
 
929
1116
  const gitignorePath = join(targetDir, ".gitignore");
930
1117
  const gitignoreExists = existsSync(gitignorePath);
@@ -970,6 +1157,7 @@ export async function createProject(
970
1157
 
971
1158
  // Parallel setup for managed mode: install + remote creation
972
1159
  let remoteResult: ManagedCreateResult | undefined;
1160
+ let urlShownEarly = false;
973
1161
 
974
1162
  if (deployMode === "managed") {
975
1163
  // Run install and remote creation in parallel
@@ -980,11 +1168,22 @@ export async function createProject(
980
1168
  const result = await runParallelSetup(targetDir, projectName, {
981
1169
  template: resolvedTemplate || "hello",
982
1170
  usePrebuilt: templateOrigin.type === "builtin", // Only builtin templates have prebuilt bundles
1171
+ onRemoteReady: (remote) => {
1172
+ // Show URL immediately when prebuilt succeeds
1173
+ reporter.stop();
1174
+ reporter.success(`Live: ${remote.runjackUrl}`);
1175
+ reporter.start("Installing dependencies locally...");
1176
+ urlShownEarly = true;
1177
+ },
983
1178
  });
984
1179
  remoteResult = result.remoteResult;
985
1180
  timings.push({ label: "Parallel setup", duration: timerEnd("parallel-setup") });
986
1181
  reporter.stop();
987
- reporter.success("Project setup complete");
1182
+ if (urlShownEarly) {
1183
+ reporter.success("Ready for local development");
1184
+ } else {
1185
+ reporter.success("Project setup complete");
1186
+ }
988
1187
  } catch (err) {
989
1188
  timerEnd("parallel-setup");
990
1189
  reporter.stop();
@@ -995,11 +1194,11 @@ export async function createProject(
995
1194
  throw err;
996
1195
  }
997
1196
  } else {
998
- // BYO mode: just install dependencies (unchanged from current)
1197
+ // BYO mode: just install dependencies
999
1198
  timerStart("bun-install");
1000
1199
  reporter.start("Installing dependencies...");
1001
1200
 
1002
- const install = Bun.spawn(["bun", "install"], {
1201
+ const install = Bun.spawn(["bun", "install", "--prefer-offline"], {
1003
1202
  cwd: targetDir,
1004
1203
  stdout: "ignore",
1005
1204
  stderr: "ignore",
@@ -1109,6 +1308,7 @@ export async function createProject(
1109
1308
  await writeTemplateMetadata(targetDir, templateOrigin);
1110
1309
  await registerPath(remoteResult.projectId, targetDir);
1111
1310
  } catch (err) {
1311
+ reporter.warn("Could not save project link (deploy still works)");
1112
1312
  debug("Failed to link managed project:", err);
1113
1313
  }
1114
1314
 
@@ -1116,7 +1316,27 @@ export async function createProject(
1116
1316
  if (remoteResult.status === "live") {
1117
1317
  // Prebuilt succeeded - skip the fresh build
1118
1318
  workerUrl = remoteResult.runjackUrl;
1119
- reporter.success(`Deployed: ${workerUrl}`);
1319
+ // Only show if not already shown by parallel setup
1320
+ if (!urlShownEarly) {
1321
+ reporter.success(`Deployed: ${workerUrl}`);
1322
+ }
1323
+
1324
+ // Upload source snapshot for forking (prebuilt path needs this too)
1325
+ try {
1326
+ const { createSourceZip } = await import("./zip-packager.ts");
1327
+ const { uploadSourceSnapshot } = await import("./control-plane.ts");
1328
+ const { rm } = await import("node:fs/promises");
1329
+
1330
+ const sourceZipPath = await createSourceZip(targetDir);
1331
+ await uploadSourceSnapshot(remoteResult.projectId, sourceZipPath);
1332
+ await rm(sourceZipPath, { force: true });
1333
+ debug("Source snapshot uploaded for prebuilt project");
1334
+ } catch (err) {
1335
+ debug(
1336
+ "Source snapshot upload failed (prebuilt):",
1337
+ err instanceof Error ? err.message : String(err),
1338
+ );
1339
+ }
1120
1340
  } else {
1121
1341
  // Prebuilt not available - fall back to fresh build
1122
1342
  if (remoteResult.prebuiltFailed) {
@@ -1226,6 +1446,7 @@ export async function createProject(
1226
1446
  await writeTemplateMetadata(targetDir, templateOrigin);
1227
1447
  await registerPath(byoProjectId, targetDir);
1228
1448
  } catch (err) {
1449
+ reporter.warn("Could not save project link (deploy still works)");
1229
1450
  debug("Failed to link BYO project:", err);
1230
1451
  }
1231
1452
  }
@@ -1249,7 +1470,7 @@ export async function createProject(
1249
1470
 
1250
1471
  // Show final celebration if there were interactive prompts (URL might have scrolled away)
1251
1472
  if (hookResult.hadInteractiveActions && reporter.celebrate) {
1252
- reporter.celebrate("You're live!", [domain]);
1473
+ reporter.celebrate("You're live!", [workerUrl]);
1253
1474
  }
1254
1475
  }
1255
1476
 
@@ -1327,6 +1548,57 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1327
1548
  "No wrangler config found in current directory",
1328
1549
  "Run: jack new <project-name>",
1329
1550
  );
1551
+ } else if (hasWranglerConfig && !hasProjectLink) {
1552
+ // Orphaned state: wrangler config exists but no project link
1553
+ // This happens when: linking failed during jack new, user has existing wrangler project,
1554
+ // or project was moved/copied without .jack directory
1555
+ const { isLoggedIn } = await import("./auth/store.ts");
1556
+ const loggedIn = await isLoggedIn();
1557
+
1558
+ if (loggedIn && !options.byo) {
1559
+ // User is logged into Jack Cloud - create managed project
1560
+ const orphanedProjectName = await getProjectNameFromDir(projectPath);
1561
+
1562
+ reporter.info(`Linking "${orphanedProjectName}" to jack cloud...`);
1563
+
1564
+ // Get username for URL construction
1565
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1566
+ const profile = await getCurrentUserProfile();
1567
+ const ownerUsername = profile?.username ?? undefined;
1568
+
1569
+ // Create managed project on jack cloud
1570
+ const remoteResult = await createManagedProjectRemote(orphanedProjectName, reporter, {
1571
+ usePrebuilt: false,
1572
+ });
1573
+
1574
+ // Link project locally
1575
+ await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername);
1576
+ await registerPath(remoteResult.projectId, projectPath);
1577
+
1578
+ // Set autoDetectResult so the rest of the flow uses managed mode
1579
+ autoDetectResult = {
1580
+ projectName: orphanedProjectName,
1581
+ projectId: remoteResult.projectId,
1582
+ deployMode: "managed",
1583
+ };
1584
+
1585
+ reporter.success("Linked to jack cloud");
1586
+ } else if (!options.managed) {
1587
+ // BYO path - ensure wrangler auth before proceeding
1588
+ await ensureCloudflareAuth(interactive, reporter);
1589
+
1590
+ // Create BYO link for tracking (non-blocking)
1591
+ const orphanedProjectName = await getProjectNameFromDir(projectPath);
1592
+ const byoProjectId = generateByoProjectId();
1593
+
1594
+ try {
1595
+ await linkProject(projectPath, byoProjectId, "byo");
1596
+ await registerPath(byoProjectId, projectPath);
1597
+ debug("Created BYO project link for orphaned project");
1598
+ } catch (err) {
1599
+ debug("Failed to create BYO project link:", err);
1600
+ }
1601
+ }
1330
1602
  }
1331
1603
 
1332
1604
  // Get project name from directory (or auto-detect result)
@@ -1475,6 +1747,9 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1475
1747
  }
1476
1748
  }
1477
1749
 
1750
+ // Ensure Cloudflare auth before BYO deploy
1751
+ await ensureCloudflareAuth(interactive, reporter);
1752
+
1478
1753
  const spin = reporter.spinner("Deploying...");
1479
1754
  const result = await runWranglerDeploy(projectPath);
1480
1755
 
@@ -1522,28 +1797,9 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1522
1797
  }
1523
1798
  }
1524
1799
 
1525
- if (includeSync && deployMode !== "byo") {
1526
- const syncConfig = await getSyncConfig();
1527
- if (syncConfig.enabled && syncConfig.autoSync) {
1528
- const syncSpin = reporter.spinner("Syncing source to jack storage...");
1529
- try {
1530
- const syncResult = await syncToCloud(projectPath);
1531
- if (syncResult.success) {
1532
- if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
1533
- syncSpin.success(
1534
- `Synced source to jack storage (${syncResult.filesUploaded} uploaded, ${syncResult.filesDeleted} removed)`,
1535
- );
1536
- } else {
1537
- syncSpin.success("Source already synced");
1538
- }
1539
- }
1540
- } catch {
1541
- syncSpin.stop();
1542
- reporter.warn("Cloud sync failed (deploy succeeded)");
1543
- reporter.info("Run: jack sync");
1544
- }
1545
- }
1546
- }
1800
+ // Note: Auto-sync to User R2 was removed for managed mode.
1801
+ // Managed projects use control-plane source.zip for clone instead.
1802
+ // BYO users can run 'jack sync' manually if needed.
1547
1803
 
1548
1804
  // Ensure project is linked locally for discovery
1549
1805
  try {
@@ -1689,7 +1945,9 @@ export async function getProjectStatus(
1689
1945
 
1690
1946
  /**
1691
1947
  * Scan for stale project paths.
1692
- * Checks for paths in the index that no longer exist on disk or don't have valid links.
1948
+ * Checks for:
1949
+ * 1. Paths in the index that no longer have wrangler config (dir deleted/moved)
1950
+ * 2. Managed projects where the cloud project no longer exists (orphaned links)
1693
1951
  * Returns total project count and stale entries with reasons.
1694
1952
  */
1695
1953
  export async function scanStaleProjects(): Promise<StaleProjectScan> {
@@ -1698,31 +1956,61 @@ export async function scanStaleProjects(): Promise<StaleProjectScan> {
1698
1956
  const stale: StaleProject[] = [];
1699
1957
  let totalPaths = 0;
1700
1958
 
1959
+ // Get list of valid managed project IDs (if logged in)
1960
+ let validManagedIds: Set<string> = new Set();
1961
+ try {
1962
+ const { isLoggedIn } = await import("./auth/store.ts");
1963
+ if (await isLoggedIn()) {
1964
+ const managedProjects = await listManagedProjects();
1965
+ validManagedIds = new Set(managedProjects.map((p) => p.id));
1966
+ }
1967
+ } catch {
1968
+ // Control plane unavailable, skip orphan detection
1969
+ }
1970
+
1701
1971
  for (const projectId of projectIds) {
1702
1972
  const paths = allPaths[projectId] || [];
1703
1973
  totalPaths += paths.length;
1704
1974
 
1705
1975
  for (const projectPath of paths) {
1706
- // Check if path exists and has valid link
1976
+ // Check if path exists and has valid wrangler config
1707
1977
  const hasWranglerConfig =
1708
1978
  existsSync(join(projectPath, "wrangler.jsonc")) ||
1709
1979
  existsSync(join(projectPath, "wrangler.toml")) ||
1710
1980
  existsSync(join(projectPath, "wrangler.json"));
1711
1981
 
1712
1982
  if (!hasWranglerConfig) {
1713
- // Try to get project name from the path
1714
- let name = projectPath.split("/").pop() || projectId;
1715
- try {
1716
- name = await getProjectNameFromDir(projectPath);
1717
- } catch {
1718
- // Use path basename as fallback
1719
- }
1720
-
1983
+ // Type 1: No wrangler config at path (dir deleted/moved)
1984
+ const name = projectPath.split("/").pop() || projectId;
1721
1985
  stale.push({
1722
1986
  name,
1723
- reason: "worker not deployed",
1987
+ reason: "directory missing or no wrangler config",
1724
1988
  workerUrl: null,
1725
1989
  });
1990
+ continue;
1991
+ }
1992
+
1993
+ // Check for Type 2: Managed project link pointing to deleted cloud project
1994
+ try {
1995
+ const link = await readProjectLink(projectPath);
1996
+ if (link?.deploy_mode === "managed" && validManagedIds.size > 0) {
1997
+ if (!validManagedIds.has(link.project_id)) {
1998
+ // Orphaned managed link - cloud project doesn't exist
1999
+ let name = projectPath.split("/").pop() || projectId;
2000
+ try {
2001
+ name = await getProjectNameFromDir(projectPath);
2002
+ } catch {
2003
+ // Use path basename as fallback
2004
+ }
2005
+ stale.push({
2006
+ name,
2007
+ reason: "cloud project deleted",
2008
+ workerUrl: null,
2009
+ });
2010
+ }
2011
+ }
2012
+ } catch {
2013
+ // Can't read link, skip
1726
2014
  }
1727
2015
  }
1728
2016
  }