@getjack/jack 0.1.2 → 0.1.4

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 (91) hide show
  1. package/README.md +77 -29
  2. package/package.json +54 -47
  3. package/src/commands/agents.ts +145 -10
  4. package/src/commands/down.ts +110 -102
  5. package/src/commands/feedback.ts +189 -0
  6. package/src/commands/init.ts +8 -12
  7. package/src/commands/login.ts +88 -0
  8. package/src/commands/logout.ts +14 -0
  9. package/src/commands/logs.ts +21 -0
  10. package/src/commands/mcp.ts +134 -7
  11. package/src/commands/new.ts +43 -17
  12. package/src/commands/open.ts +13 -6
  13. package/src/commands/projects.ts +269 -143
  14. package/src/commands/secrets.ts +413 -0
  15. package/src/commands/services.ts +96 -123
  16. package/src/commands/ship.ts +5 -1
  17. package/src/commands/whoami.ts +31 -0
  18. package/src/index.ts +218 -144
  19. package/src/lib/agent-files.ts +34 -0
  20. package/src/lib/agents.ts +390 -22
  21. package/src/lib/asset-hash.ts +50 -0
  22. package/src/lib/auth/client.ts +115 -0
  23. package/src/lib/auth/constants.ts +5 -0
  24. package/src/lib/auth/guard.ts +57 -0
  25. package/src/lib/auth/index.ts +18 -0
  26. package/src/lib/auth/store.ts +54 -0
  27. package/src/lib/binding-validator.ts +136 -0
  28. package/src/lib/build-helper.ts +211 -0
  29. package/src/lib/cloudflare-api.ts +24 -0
  30. package/src/lib/config.ts +5 -6
  31. package/src/lib/control-plane.ts +295 -0
  32. package/src/lib/debug.ts +3 -1
  33. package/src/lib/deploy-mode.ts +93 -0
  34. package/src/lib/deploy-upload.ts +92 -0
  35. package/src/lib/errors.ts +2 -0
  36. package/src/lib/github.ts +31 -1
  37. package/src/lib/hooks.ts +4 -12
  38. package/src/lib/intent.ts +88 -0
  39. package/src/lib/jsonc.ts +125 -0
  40. package/src/lib/local-paths.test.ts +902 -0
  41. package/src/lib/local-paths.ts +258 -0
  42. package/src/lib/managed-deploy.ts +175 -0
  43. package/src/lib/managed-down.ts +159 -0
  44. package/src/lib/mcp-config.ts +55 -34
  45. package/src/lib/names.ts +9 -29
  46. package/src/lib/project-operations.ts +676 -249
  47. package/src/lib/project-resolver.ts +476 -0
  48. package/src/lib/registry.ts +76 -37
  49. package/src/lib/resources.ts +196 -0
  50. package/src/lib/schema.ts +30 -1
  51. package/src/lib/storage/file-filter.ts +1 -0
  52. package/src/lib/storage/index.ts +5 -1
  53. package/src/lib/telemetry.ts +14 -0
  54. package/src/lib/tty.ts +15 -0
  55. package/src/lib/zip-packager.ts +255 -0
  56. package/src/mcp/resources/index.ts +8 -2
  57. package/src/mcp/server.ts +32 -4
  58. package/src/mcp/tools/index.ts +35 -13
  59. package/src/mcp/types.ts +6 -0
  60. package/src/mcp/utils.ts +1 -1
  61. package/src/templates/index.ts +42 -4
  62. package/src/templates/types.ts +13 -0
  63. package/templates/CLAUDE.md +166 -0
  64. package/templates/api/.jack.json +4 -0
  65. package/templates/api/bun.lock +1 -0
  66. package/templates/api/wrangler.jsonc +5 -0
  67. package/templates/hello/.jack.json +28 -0
  68. package/templates/hello/package.json +10 -0
  69. package/templates/hello/src/index.ts +11 -0
  70. package/templates/hello/tsconfig.json +11 -0
  71. package/templates/hello/wrangler.jsonc +5 -0
  72. package/templates/miniapp/.jack.json +15 -4
  73. package/templates/miniapp/bun.lock +135 -40
  74. package/templates/miniapp/index.html +1 -0
  75. package/templates/miniapp/package.json +3 -1
  76. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  77. package/templates/miniapp/public/icon.png +0 -0
  78. package/templates/miniapp/public/og.png +0 -0
  79. package/templates/miniapp/schema.sql +8 -0
  80. package/templates/miniapp/src/App.tsx +254 -3
  81. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  82. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  83. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  84. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  85. package/templates/miniapp/src/index.css +15 -0
  86. package/templates/miniapp/src/lib/api.ts +2 -1
  87. package/templates/miniapp/src/worker.ts +515 -1
  88. package/templates/miniapp/wrangler.jsonc +15 -3
  89. package/LICENSE +0 -190
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
@@ -8,28 +8,46 @@
8
8
  import { existsSync } from "node:fs";
9
9
  import { join, resolve } from "node:path";
10
10
  import { $ } from "bun";
11
- import { renderTemplate, resolveTemplate } from "../templates/index.ts";
11
+ import {
12
+ BUILTIN_TEMPLATES,
13
+ type ResolvedTemplate,
14
+ renderTemplate,
15
+ resolveTemplateWithOrigin,
16
+ } from "../templates/index.ts";
12
17
  import type { Template } from "../templates/types.ts";
13
18
  import { generateAgentFiles } from "./agent-files.ts";
14
- import { getActiveAgents, validateAgentPaths } from "./agents.ts";
15
- import { getAccountId } from "./cloudflare-api.ts";
16
- import { checkWorkerExists } from "./cloudflare-api.ts";
19
+ import {
20
+ getActiveAgents,
21
+ getAgentDefinition,
22
+ getOneShotAgent,
23
+ runAgentOneShot,
24
+ validateAgentPaths,
25
+ } from "./agents.ts";
26
+ import { needsViteBuild, runViteBuild } from "./build-helper.ts";
27
+ import { checkWorkerExists, getAccountId, listD1Databases } from "./cloudflare-api.ts";
17
28
  import { getSyncConfig } from "./config.ts";
29
+ import { debug, isDebug } from "./debug.ts";
30
+ import { resolveDeployMode, validateModeAvailability } from "./deploy-mode.ts";
18
31
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
19
32
  import { JackError, JackErrorCode } from "./errors.ts";
20
33
  import { type HookOutput, runHook } from "./hooks.ts";
34
+ import { loadTemplateKeywords, matchTemplateByIntent } from "./intent.ts";
35
+ import { registerLocalPath } from "./local-paths.ts";
36
+ import { createManagedProjectRemote, deployToManagedProject } from "./managed-deploy.ts";
21
37
  import { generateProjectName } from "./names.ts";
22
38
  import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
39
+ import type { DeployMode, TemplateOrigin } from "./registry.ts";
23
40
  import {
24
41
  getAllProjects,
25
42
  getProject,
26
- getProjectDatabaseName,
27
43
  registerProject,
28
44
  removeProject,
45
+ updateProject,
29
46
  } from "./registry.ts";
30
- import { applySchema, getD1DatabaseName, hasD1Config } from "./schema.ts";
31
- import { getSavedSecrets } from "./secrets.ts";
47
+ import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./schema.ts";
48
+ import { getSavedSecrets, saveSecrets } from "./secrets.ts";
32
49
  import { getProjectNameFromDir, getRemoteManifest, syncToCloud } from "./storage/index.ts";
50
+ import { Events, track } from "./telemetry.ts";
33
51
 
34
52
  // ============================================================================
35
53
  // Type Definitions
@@ -37,14 +55,18 @@ import { getProjectNameFromDir, getRemoteManifest, syncToCloud } from "./storage
37
55
 
38
56
  export interface CreateProjectOptions {
39
57
  template?: string;
58
+ intent?: string;
40
59
  reporter?: OperationReporter;
41
60
  interactive?: boolean;
61
+ managed?: boolean; // Force managed deploy mode
62
+ byo?: boolean; // Force BYO deploy mode
42
63
  }
43
64
 
44
65
  export interface CreateProjectResult {
45
66
  projectName: string;
46
67
  targetDir: string;
47
68
  workerUrl: string | null;
69
+ deployMode: DeployMode; // The deploy mode used
48
70
  }
49
71
 
50
72
  export interface DeployOptions {
@@ -53,12 +75,15 @@ export interface DeployOptions {
53
75
  interactive?: boolean;
54
76
  includeSecrets?: boolean;
55
77
  includeSync?: boolean;
78
+ managed?: boolean; // Force managed deploy mode
79
+ byo?: boolean; // Force BYO deploy mode
56
80
  }
57
81
 
58
82
  export interface DeployResult {
59
83
  workerUrl: string | null;
60
84
  projectName: string;
61
85
  deployOutput?: string;
86
+ deployMode: DeployMode; // The deploy mode used
62
87
  }
63
88
 
64
89
  export interface ProjectStatus {
@@ -80,7 +105,7 @@ export interface ProjectStatus {
80
105
 
81
106
  export interface StaleProject {
82
107
  name: string;
83
- reason: "local folder deleted" | "undeployed from cloud";
108
+ reason: "worker not deployed";
84
109
  workerUrl: string | null;
85
110
  }
86
111
 
@@ -93,6 +118,7 @@ export interface OperationSpinner {
93
118
  success(message: string): void;
94
119
  error(message: string): void;
95
120
  stop(): void;
121
+ text?: string;
96
122
  }
97
123
 
98
124
  export interface OperationReporter extends HookOutput {
@@ -120,6 +146,63 @@ const noopReporter: OperationReporter = {
120
146
  box() {},
121
147
  };
122
148
 
149
+ const DEFAULT_D1_LIMIT = 10;
150
+
151
+ async function preflightD1Capacity(
152
+ projectDir: string,
153
+ reporter: OperationReporter,
154
+ interactive: boolean,
155
+ ): Promise<void> {
156
+ const bindings = await getD1Bindings(projectDir);
157
+ const needsCreate = bindings.some((binding) => !binding.database_id && binding.database_name);
158
+ if (!needsCreate) {
159
+ return;
160
+ }
161
+
162
+ let databases: Array<{ name?: string; uuid?: string }>;
163
+ try {
164
+ databases = await listD1Databases();
165
+ } catch (err) {
166
+ reporter.warn("Could not check D1 limits before deploy");
167
+ if (err instanceof Error) {
168
+ reporter.info(err.message);
169
+ }
170
+ return;
171
+ }
172
+
173
+ const count = databases.length;
174
+ if (count < DEFAULT_D1_LIMIT) {
175
+ return;
176
+ }
177
+
178
+ reporter.warn(`D1 limit likely reached: ${count} databases`);
179
+ reporter.info("Delete old D1 databases with: wrangler d1 list / wrangler d1 delete <name>");
180
+ reporter.info("Or reuse an existing database by setting database_id in wrangler.jsonc");
181
+
182
+ if (!interactive) {
183
+ return;
184
+ }
185
+
186
+ const { promptSelect } = await import("./hooks.ts");
187
+ console.error("");
188
+ console.error(
189
+ ` You have ${count} D1 databases. If your limit is ${DEFAULT_D1_LIMIT}, deploy may fail.`,
190
+ );
191
+ console.error("");
192
+ console.error(" Continue anyway?");
193
+
194
+ const choice = await promptSelect(["Yes", "No"]);
195
+
196
+ if (choice !== 0) {
197
+ throw new JackError(
198
+ JackErrorCode.VALIDATION_ERROR,
199
+ "D1 limit likely reached",
200
+ "Delete old D1 databases or reuse an existing database_id",
201
+ { exitCode: 0, reported: true },
202
+ );
203
+ }
204
+ }
205
+
123
206
  // ============================================================================
124
207
  // Create Project Operation
125
208
  // ============================================================================
@@ -138,11 +221,20 @@ export async function createProject(
138
221
  name?: string,
139
222
  options: CreateProjectOptions = {},
140
223
  ): Promise<CreateProjectResult> {
141
- const { template: templateOption, reporter: providedReporter, interactive: interactiveOption } =
142
- options;
224
+ const {
225
+ template: templateOption,
226
+ intent: intentPhrase,
227
+ reporter: providedReporter,
228
+ interactive: interactiveOption,
229
+ } = options;
143
230
  const reporter = providedReporter ?? noopReporter;
144
231
  const hasReporter = Boolean(providedReporter);
145
- const isCi = process.env.CI === "true" || process.env.CI === "1";
232
+ // CI mode: JACK_CI env or standard CI env
233
+ const isCi =
234
+ process.env.JACK_CI === "1" ||
235
+ process.env.JACK_CI === "true" ||
236
+ process.env.CI === "true" ||
237
+ process.env.CI === "1";
146
238
  const interactive = interactiveOption ?? !isCi;
147
239
 
148
240
  // Check if jack init was run (throws if not)
@@ -152,24 +244,195 @@ export async function createProject(
152
244
  throw new JackError(JackErrorCode.VALIDATION_ERROR, "jack is not set up yet", "Run: jack init");
153
245
  }
154
246
 
247
+ // Resolve deploy mode (omakase: logged in => managed, logged out => BYO)
248
+ const deployMode = await resolveDeployMode({
249
+ managed: options.managed,
250
+ byo: options.byo,
251
+ });
252
+ const modeError = await validateModeAvailability(deployMode);
253
+ if (modeError) {
254
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);
255
+ }
256
+
257
+ // Close the "Starting..." spinner from new.ts
258
+ reporter.stop();
259
+ reporter.success("Initialized");
260
+
155
261
  // Generate or use provided name
262
+ const nameWasProvided = name !== undefined;
156
263
  const projectName = name ?? generateProjectName();
157
264
  const targetDir = resolve(projectName);
158
265
 
159
266
  // Check directory doesn't exist
160
267
  if (existsSync(targetDir)) {
161
- throw new JackError(
162
- JackErrorCode.VALIDATION_ERROR,
163
- `Directory ${projectName} already exists`,
164
- );
268
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, `Directory ${projectName} already exists`);
269
+ }
270
+
271
+ // Early slug availability check for managed mode (only if user provided explicit name)
272
+ // Skip for auto-generated names - collision is rare, control plane will catch it anyway
273
+ if (deployMode === "managed" && nameWasProvided) {
274
+ reporter.start("Checking name availability...");
275
+ const { checkAvailability } = await import("./project-resolver.ts");
276
+ const { available, existingProject } = await checkAvailability(projectName);
277
+ reporter.stop();
278
+ if (available) {
279
+ reporter.success("Name available");
280
+ }
281
+
282
+ if (!available && existingProject) {
283
+ // Project exists remotely but not locally - offer to link
284
+ if (existingProject.sources.controlPlane && !existingProject.sources.filesystem) {
285
+ if (interactive) {
286
+ const { promptSelect } = await import("./hooks.ts");
287
+ console.error("");
288
+ console.error(` Project "${projectName}" exists on jack cloud but not locally.`);
289
+ console.error("");
290
+
291
+ const choice = await promptSelect(["Link existing project", "Choose different name"]);
292
+
293
+ 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}`);
312
+ // Continue with project creation - user wants to link
313
+ } else {
314
+ // User chose different name
315
+ throw new JackError(
316
+ JackErrorCode.VALIDATION_ERROR,
317
+ `Project "${projectName}" already exists on jack cloud`,
318
+ `Try a different name: jack new ${projectName}-2`,
319
+ { exitCode: 0, reported: true },
320
+ );
321
+ }
322
+ } else {
323
+ // Non-interactive mode - fail with clear message
324
+ throw new JackError(
325
+ JackErrorCode.VALIDATION_ERROR,
326
+ `Project "${projectName}" already exists on jack cloud`,
327
+ `Try a different name: jack new ${projectName}-2`,
328
+ );
329
+ }
330
+ } else {
331
+ // Project exists in registry with local path - it's truly taken
332
+ throw new JackError(
333
+ JackErrorCode.VALIDATION_ERROR,
334
+ `Project "${projectName}" already exists`,
335
+ `Try a different name: jack new ${projectName}-2`,
336
+ );
337
+ }
338
+ }
165
339
  }
166
340
 
167
341
  reporter.start("Creating project...");
168
342
 
169
- // Load template
343
+ // Intent-based template matching
344
+ let resolvedTemplate = templateOption;
345
+
346
+ if (intentPhrase && !templateOption) {
347
+ reporter.start("Matching intent to template...");
348
+
349
+ const templates = await loadTemplateKeywords();
350
+ const matches = matchTemplateByIntent(intentPhrase, templates);
351
+
352
+ reporter.stop();
353
+
354
+ if (matches.length === 0) {
355
+ // Track no match
356
+ track(Events.INTENT_NO_MATCH, {});
357
+
358
+ // No match - prompt user to choose
359
+ if (interactive) {
360
+ const { select } = await import("@clack/prompts");
361
+ console.error("");
362
+ console.error(` No template matched for: "${intentPhrase}"`);
363
+ console.error("");
364
+
365
+ const choice = await select({
366
+ message: "Select a template:",
367
+ options: BUILTIN_TEMPLATES.map((t, i) => ({ value: t, label: `${i + 1}. ${t}` })),
368
+ });
369
+
370
+ if (typeof choice !== "string") {
371
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "No template selected", undefined, {
372
+ exitCode: 0,
373
+ reported: true,
374
+ });
375
+ }
376
+ resolvedTemplate = choice;
377
+ } else {
378
+ throw new JackError(
379
+ JackErrorCode.VALIDATION_ERROR,
380
+ `No template matched intent: "${intentPhrase}"`,
381
+ `Available templates: ${BUILTIN_TEMPLATES.join(", ")}`,
382
+ );
383
+ }
384
+ } else if (matches.length === 1) {
385
+ resolvedTemplate = matches[0]?.template;
386
+ reporter.success(`Matched template: ${resolvedTemplate}`);
387
+
388
+ // Track single match
389
+ track(Events.INTENT_MATCHED, {
390
+ template: resolvedTemplate,
391
+ match_count: 1,
392
+ });
393
+ } else {
394
+ // Track multiple matches
395
+ track(Events.INTENT_MATCHED, {
396
+ template: matches[0]?.template,
397
+ match_count: matches.length,
398
+ });
399
+
400
+ // Multiple matches
401
+ if (interactive) {
402
+ const { select } = await import("@clack/prompts");
403
+ console.error("");
404
+ console.error(` Multiple templates matched: "${intentPhrase}"`);
405
+ console.error("");
406
+
407
+ const matchedNames = matches.map((m) => m.template);
408
+ const choice = await select({
409
+ message: "Select a template:",
410
+ options: matchedNames.map((t, i) => ({ value: t, label: `${i + 1}. ${t}` })),
411
+ });
412
+
413
+ if (typeof choice !== "string") {
414
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "No template selected", undefined, {
415
+ exitCode: 0,
416
+ reported: true,
417
+ });
418
+ }
419
+ resolvedTemplate = choice;
420
+ } else {
421
+ resolvedTemplate = matches[0]?.template;
422
+ reporter.info(`Multiple matches, using: ${resolvedTemplate}`);
423
+ }
424
+ }
425
+
426
+ reporter.start("Creating project...");
427
+ }
428
+
429
+ // Load template with origin tracking for lineage
170
430
  let template: Template;
431
+ let templateOrigin: TemplateOrigin;
171
432
  try {
172
- template = await resolveTemplate(templateOption);
433
+ const resolved = await resolveTemplateWithOrigin(resolvedTemplate);
434
+ template = resolved.template;
435
+ templateOrigin = resolved.origin;
173
436
  } catch (err) {
174
437
  reporter.stop();
175
438
  const message = err instanceof Error ? err.message : String(err);
@@ -208,11 +471,75 @@ export async function createProject(
208
471
  reporter.start("Creating project...");
209
472
  }
210
473
 
474
+ // Handle optional secrets (only in interactive mode)
475
+ if (template.optionalSecrets?.length && interactive) {
476
+ const saved = await getSavedSecrets();
477
+
478
+ for (const optionalSecret of template.optionalSecrets) {
479
+ // Skip if already saved
480
+ const savedValue = saved[optionalSecret.name];
481
+ if (savedValue) {
482
+ secretsToUse[optionalSecret.name] = savedValue;
483
+ reporter.stop();
484
+ reporter.success(`Using saved secret: ${optionalSecret.name}`);
485
+ reporter.start("Creating project...");
486
+ continue;
487
+ }
488
+
489
+ // Prompt user
490
+ reporter.stop();
491
+ const { input, select } = await import("@inquirer/prompts");
492
+ console.error("");
493
+ console.error(` ${optionalSecret.description}`);
494
+ if (optionalSecret.setupUrl) {
495
+ console.error(` Setup: ${optionalSecret.setupUrl}`);
496
+ }
497
+ console.error("");
498
+ console.error(" Esc to skip\n");
499
+
500
+ const choice = await select({
501
+ message: `Add ${optionalSecret.name}?`,
502
+ choices: [
503
+ { name: "1. Yes", value: "yes" },
504
+ { name: "2. Skip", value: "skip" },
505
+ ],
506
+ });
507
+
508
+ if (choice === "yes") {
509
+ const value = await input({
510
+ message: `Enter ${optionalSecret.name}:`,
511
+ });
512
+
513
+ if (value.trim()) {
514
+ secretsToUse[optionalSecret.name] = value.trim();
515
+ // Save to global secrets for reuse
516
+ await saveSecrets([
517
+ {
518
+ key: optionalSecret.name,
519
+ value: value.trim(),
520
+ source: "optional-template",
521
+ },
522
+ ]);
523
+ reporter.success(`Saved ${optionalSecret.name}`);
524
+ }
525
+ }
526
+
527
+ reporter.start("Creating project...");
528
+ }
529
+ }
530
+
211
531
  // Write all template files
212
532
  for (const [filePath, content] of Object.entries(rendered.files)) {
213
533
  await Bun.write(join(targetDir, filePath), content);
214
534
  }
215
535
 
536
+ // Preflight: check D1 capacity before spending time on installs (BYO only)
537
+ reporter.stop();
538
+ if (deployMode === "byo") {
539
+ await preflightD1Capacity(targetDir, reporter, interactive);
540
+ }
541
+ reporter.start("Creating project...");
542
+
216
543
  // Write secrets files (.env for Vite, .dev.vars for wrangler local, .secrets.json for wrangler bulk)
217
544
  if (Object.keys(secretsToUse).length > 0) {
218
545
  const envContent = generateEnvFile(secretsToUse);
@@ -309,113 +636,219 @@ export async function createProject(
309
636
  }
310
637
  }
311
638
 
312
- // For Vite projects, build first
313
- const hasVite = existsSync(join(targetDir, "vite.config.ts"));
314
- if (hasVite) {
315
- reporter.start("Building...");
639
+ // One-shot agent customization if intent was provided
640
+ if (intentPhrase) {
641
+ const oneShotAgent = await getOneShotAgent();
642
+
643
+ if (oneShotAgent) {
644
+ const agentDefinition = getAgentDefinition(oneShotAgent);
645
+ const agentLabel = agentDefinition?.name ?? oneShotAgent;
646
+ reporter.info(`Customizing with ${agentLabel}`);
647
+ reporter.info(`Intent: ${intentPhrase}`);
648
+ const debugEnabled = isDebug();
649
+ const customizationSpinner = debugEnabled ? null : reporter.spinner("Customizing...");
650
+
651
+ // Track customization start
652
+ track(Events.INTENT_CUSTOMIZATION_STARTED, { agent: oneShotAgent });
653
+
654
+ const result = await runAgentOneShot(oneShotAgent, targetDir, intentPhrase, {
655
+ info: reporter.info,
656
+ warn: reporter.warn,
657
+ status: customizationSpinner
658
+ ? (message) => {
659
+ customizationSpinner.text = message;
660
+ }
661
+ : undefined,
662
+ });
316
663
 
317
- const buildResult = await $`npx vite build`.cwd(targetDir).nothrow().quiet();
318
- if (buildResult.exitCode !== 0) {
319
- reporter.stop();
320
- reporter.error("Build failed");
321
- throw new JackError(
322
- JackErrorCode.BUILD_FAILED,
323
- "Build failed",
324
- undefined,
325
- { exitCode: 0, stderr: buildResult.stderr.toString(), reported: hasReporter },
326
- );
664
+ if (customizationSpinner) {
665
+ customizationSpinner.stop();
666
+ }
667
+ if (result.success) {
668
+ reporter.success("Project customized");
669
+ // Track successful customization
670
+ track(Events.INTENT_CUSTOMIZATION_COMPLETED, { agent: oneShotAgent });
671
+ } else {
672
+ reporter.warn(`Customization skipped: ${result.error ?? "unknown error"}`);
673
+ // Track failed customization
674
+ track(Events.INTENT_CUSTOMIZATION_FAILED, {
675
+ agent: oneShotAgent,
676
+ error_type: "agent_error",
677
+ });
678
+ }
679
+ } else {
680
+ reporter.info?.("No compatible agent for customization (Claude Code or Codex required)");
327
681
  }
328
-
329
- reporter.stop();
330
- reporter.success("Built");
331
682
  }
332
683
 
333
- // Deploy
334
- reporter.start("Deploying...");
684
+ let workerUrl: string | null = null;
335
685
 
336
- const deployResult = await $`wrangler deploy`.cwd(targetDir).nothrow().quiet();
686
+ // Deploy based on mode
687
+ 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
+ });
337
693
 
338
- if (deployResult.exitCode !== 0) {
339
- reporter.stop();
340
- reporter.error("Deploy failed");
341
- throw new JackError(
342
- JackErrorCode.DEPLOY_FAILED,
343
- "Deploy failed",
344
- undefined,
345
- { exitCode: 0, stderr: deployResult.stderr.toString(), reported: hasReporter },
346
- );
347
- }
694
+ // Register project as soon as remote is created
695
+ 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
+ });
710
+ } catch (err) {
711
+ debug("Failed to register managed project:", err);
712
+ }
713
+
714
+ // Check if prebuilt deployment succeeded
715
+ if (remoteResult.status === "live") {
716
+ // Prebuilt succeeded - skip the fresh build
717
+ workerUrl = remoteResult.runjackUrl;
718
+ reporter.success(`Deployed: ${workerUrl}`);
719
+ } else {
720
+ // Prebuilt not available - fall back to fresh build
721
+ if (remoteResult.prebuiltFailed) {
722
+ // Show debug info about why prebuilt failed
723
+ const errorDetail = remoteResult.prebuiltError ? ` (${remoteResult.prebuiltError})` : "";
724
+ debug(`Prebuilt failed${errorDetail}`);
725
+ reporter.info("Pre-built not available, building fresh...");
726
+ }
348
727
 
349
- // Apply schema.sql after deploy
350
- if (await hasD1Config(targetDir)) {
351
- const dbName = await getD1DatabaseName(targetDir);
352
- if (dbName) {
353
728
  try {
354
- await applySchema(dbName, targetDir);
729
+ await deployToManagedProject(remoteResult.projectId, targetDir, reporter);
355
730
  } catch (err) {
356
- reporter.warn(`Schema application failed: ${err}`);
357
- reporter.info("Run manually: bun run db:migrate");
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
+ }
741
+ workerUrl = remoteResult.runjackUrl;
742
+ 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);
358
753
  }
359
754
  }
360
- }
755
+ } else {
756
+ // BYO mode: deploy via wrangler
361
757
 
362
- // Push secrets to Cloudflare
363
- const secretsJsonPath = join(targetDir, ".secrets.json");
364
- if (existsSync(secretsJsonPath)) {
365
- reporter.start("Configuring secrets...");
758
+ // Build first if needed (wrangler needs dist/ for assets)
759
+ if (await needsViteBuild(targetDir)) {
760
+ reporter.start("Building...");
761
+ try {
762
+ await runViteBuild(targetDir);
763
+ reporter.stop();
764
+ reporter.success("Built");
765
+ } catch (err) {
766
+ reporter.stop();
767
+ reporter.error("Build failed");
768
+ throw err;
769
+ }
770
+ }
366
771
 
367
- const secretsResult = await $`wrangler secret bulk .secrets.json`
368
- .cwd(targetDir)
369
- .nothrow()
370
- .quiet();
772
+ reporter.start("Deploying...");
371
773
 
372
- if (secretsResult.exitCode !== 0) {
373
- reporter.stop();
374
- reporter.warn("Failed to push secrets to Cloudflare");
375
- reporter.info("Run manually: wrangler secret bulk .secrets.json");
376
- } else {
774
+ const deployResult = await $`wrangler deploy`.cwd(targetDir).nothrow().quiet();
775
+
776
+ if (deployResult.exitCode !== 0) {
377
777
  reporter.stop();
378
- reporter.success("Secrets configured");
778
+ reporter.error("Deploy failed");
779
+ throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
780
+ exitCode: 0,
781
+ stderr: deployResult.stderr.toString(),
782
+ reported: hasReporter,
783
+ });
379
784
  }
380
- }
381
785
 
382
- // Parse URL from output
383
- const deployOutput = deployResult.stdout.toString();
384
- const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
385
- const workerUrl = urlMatch ? urlMatch[0] : null;
786
+ // Apply schema.sql after deploy
787
+ if (await hasD1Config(targetDir)) {
788
+ const dbName = await getD1DatabaseName(targetDir);
789
+ if (dbName) {
790
+ try {
791
+ await applySchema(dbName, targetDir);
792
+ } catch (err) {
793
+ reporter.warn(`Schema application failed: ${err}`);
794
+ reporter.info("Run manually: bun run db:migrate");
795
+ }
796
+ }
797
+ }
386
798
 
387
- reporter.stop();
388
- if (workerUrl) {
389
- reporter.success(`Live: ${workerUrl}`);
390
- } else {
391
- reporter.success("Deployed");
392
- }
799
+ // Push secrets to Cloudflare
800
+ const secretsJsonPath = join(targetDir, ".secrets.json");
801
+ if (existsSync(secretsJsonPath)) {
802
+ reporter.start("Configuring secrets...");
393
803
 
394
- // Register project in registry
395
- try {
396
- const accountId = await getAccountId();
397
- const dbName = await getD1DatabaseName(targetDir);
804
+ const secretsResult = await $`wrangler secret bulk .secrets.json`
805
+ .cwd(targetDir)
806
+ .nothrow()
807
+ .quiet();
398
808
 
399
- await registerProject(projectName, {
400
- localPath: targetDir,
401
- workerUrl,
402
- createdAt: new Date().toISOString(),
403
- lastDeployed: workerUrl ? new Date().toISOString() : null,
404
- cloudflare: {
405
- accountId,
406
- workerId: projectName,
407
- },
408
- resources: {
409
- services: {
410
- db: dbName,
809
+ if (secretsResult.exitCode !== 0) {
810
+ reporter.stop();
811
+ reporter.warn("Failed to push secrets to Cloudflare");
812
+ reporter.info("Run manually: wrangler secret bulk .secrets.json");
813
+ } else {
814
+ reporter.stop();
815
+ reporter.success("Secrets configured");
816
+ }
817
+ }
818
+
819
+ // Parse URL from output
820
+ const deployOutput = deployResult.stdout.toString();
821
+ const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
822
+ workerUrl = urlMatch ? urlMatch[0] : null;
823
+
824
+ reporter.stop();
825
+ if (workerUrl) {
826
+ reporter.success(`Live: ${workerUrl}`);
827
+ } else {
828
+ reporter.success("Deployed");
829
+ }
830
+
831
+ // Register project with BYO mode
832
+ 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,
411
842
  },
412
- },
413
- });
414
- } catch {
415
- // Don't fail the creation if registry update fails
843
+ template: templateOrigin,
844
+ deploy_mode: "byo",
845
+ });
846
+ } catch {
847
+ // Don't fail the creation if registry update fails
848
+ }
416
849
  }
417
850
 
418
- // Run post-deploy hooks
851
+ // Run post-deploy hooks (for both modes)
419
852
  if (template.hooks?.postDeploy?.length && workerUrl) {
420
853
  const domain = workerUrl.replace(/^https?:\/\//, "");
421
854
  await runHook(
@@ -430,10 +863,18 @@ export async function createProject(
430
863
  );
431
864
  }
432
865
 
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
+
433
873
  return {
434
874
  projectName,
435
875
  targetDir,
436
876
  workerUrl,
877
+ deployMode,
437
878
  };
438
879
  }
439
880
 
@@ -460,7 +901,12 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
460
901
  } = options;
461
902
  const reporter = providedReporter ?? noopReporter;
462
903
  const hasReporter = Boolean(providedReporter);
463
- const isCi = process.env.CI === "true" || process.env.CI === "1";
904
+ // CI mode: JACK_CI env or standard CI env
905
+ const isCi =
906
+ process.env.JACK_CI === "1" ||
907
+ process.env.JACK_CI === "true" ||
908
+ process.env.CI === "true" ||
909
+ process.env.CI === "1";
464
910
  const interactive = interactiveOption ?? !isCi;
465
911
 
466
912
  // Check for wrangler config
@@ -477,55 +923,93 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
477
923
  );
478
924
  }
479
925
 
480
- // For Vite projects, build first
481
- const isViteProject =
482
- existsSync(join(projectPath, "vite.config.ts")) ||
483
- existsSync(join(projectPath, "vite.config.js")) ||
484
- existsSync(join(projectPath, "vite.config.mjs"));
926
+ // Get project name from directory
927
+ const projectName = await getProjectNameFromDir(projectPath);
485
928
 
486
- if (isViteProject) {
487
- const buildSpin = reporter.spinner("Building...");
488
- const buildResult = await $`npx vite build`.cwd(projectPath).nothrow().quiet();
929
+ // Get project from registry to check stored mode
930
+ const project = await getProject(projectName);
489
931
 
490
- if (buildResult.exitCode !== 0) {
491
- buildSpin.error("Build failed");
492
- throw new JackError(JackErrorCode.BUILD_FAILED, "Build failed", undefined, {
493
- exitCode: buildResult.exitCode ?? 1,
494
- stderr: buildResult.stderr.toString(),
495
- reported: hasReporter,
496
- });
497
- }
498
- buildSpin.success("Built");
932
+ // Determine effective mode: explicit flag > stored mode > default BYO
933
+ let deployMode: DeployMode;
934
+ if (options.managed) {
935
+ deployMode = "managed";
936
+ } else if (options.byo) {
937
+ deployMode = "byo";
938
+ } else {
939
+ deployMode = project?.deploy_mode ?? "byo";
499
940
  }
500
941
 
501
- // Deploy
502
- const spin = reporter.spinner("Deploying...");
503
- const result = await $`wrangler deploy`.cwd(projectPath).nothrow().quiet();
504
-
505
- if (result.exitCode !== 0) {
506
- spin.error("Deploy failed");
507
- throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
508
- exitCode: result.exitCode ?? 1,
509
- stderr: result.stderr.toString(),
510
- reported: hasReporter,
511
- });
942
+ // Validate mode availability
943
+ const modeError = await validateModeAvailability(deployMode);
944
+ if (modeError) {
945
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);
512
946
  }
513
947
 
514
- // Parse URL from output
515
- const deployOutput = result.stdout.toString();
516
- const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
517
- const workerUrl = urlMatch ? urlMatch[0] : null;
518
- const projectName = await getProjectNameFromDir(projectPath);
948
+ let workerUrl: string | null = null;
949
+ let deployOutput: string | undefined;
519
950
 
520
- if (workerUrl) {
521
- spin.success(`Live: ${workerUrl}`);
951
+ // Deploy based on mode
952
+ if (deployMode === "managed") {
953
+ // Managed mode: deploy via jack cloud
954
+ if (!project?.remote?.project_id) {
955
+ throw new JackError(
956
+ JackErrorCode.VALIDATION_ERROR,
957
+ "Project not linked to jack cloud",
958
+ "Create a new managed project or use --byo",
959
+ );
960
+ }
961
+
962
+ // 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;
966
+
967
+ // Update lastDeployed in registry (will be persisted below)
968
+ if (project) {
969
+ project.lastDeployed = new Date().toISOString();
970
+ }
522
971
  } else {
523
- spin.success("Deployed");
972
+ // BYO mode: deploy via wrangler
973
+
974
+ // Build first if needed (wrangler needs dist/ for assets)
975
+ if (await needsViteBuild(projectPath)) {
976
+ const buildSpin = reporter.spinner("Building...");
977
+ try {
978
+ await runViteBuild(projectPath);
979
+ buildSpin.success("Built");
980
+ } catch (err) {
981
+ buildSpin.error("Build failed");
982
+ throw err;
983
+ }
984
+ }
985
+
986
+ const spin = reporter.spinner("Deploying...");
987
+ const result = await $`wrangler deploy`.cwd(projectPath).nothrow().quiet();
988
+
989
+ if (result.exitCode !== 0) {
990
+ spin.error("Deploy failed");
991
+ throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
992
+ exitCode: result.exitCode ?? 1,
993
+ stderr: result.stderr.toString(),
994
+ reported: hasReporter,
995
+ });
996
+ }
997
+
998
+ // Parse URL from output
999
+ deployOutput = result.stdout.toString();
1000
+ const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
1001
+ workerUrl = urlMatch ? urlMatch[0] : null;
1002
+
1003
+ if (workerUrl) {
1004
+ spin.success(`Live: ${workerUrl}`);
1005
+ } else {
1006
+ spin.success("Deployed");
1007
+ }
524
1008
  }
525
1009
 
526
- // Apply schema if needed
1010
+ // Apply schema if needed (BYO only - managed projects have their own DB)
527
1011
  let dbName: string | null = null;
528
- if (await hasD1Config(projectPath)) {
1012
+ if (deployMode === "byo" && (await hasD1Config(projectPath))) {
529
1013
  dbName = await getD1DatabaseName(projectPath);
530
1014
  if (dbName) {
531
1015
  try {
@@ -540,14 +1024,8 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
540
1024
  // Update registry
541
1025
  try {
542
1026
  await registerProject(projectName, {
543
- localPath: projectPath,
544
1027
  workerUrl,
545
1028
  lastDeployed: new Date().toISOString(),
546
- resources: {
547
- services: {
548
- db: dbName,
549
- },
550
- },
551
1029
  });
552
1030
  } catch {
553
1031
  // Don't fail the deploy if registry update fails
@@ -585,10 +1063,18 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
585
1063
  }
586
1064
  }
587
1065
 
1066
+ // Auto-register local path for project discovery
1067
+ try {
1068
+ await registerLocalPath(projectName, projectPath);
1069
+ } catch {
1070
+ // Silent fail - registration is best-effort
1071
+ }
1072
+
588
1073
  return {
589
1074
  workerUrl,
590
1075
  projectName,
591
1076
  deployOutput: workerUrl ? undefined : deployOutput,
1077
+ deployMode,
592
1078
  };
593
1079
  }
594
1080
 
@@ -602,7 +1088,7 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
602
1088
  * Extracted from commands/projects.ts infoProject to enable programmatic status checks.
603
1089
  *
604
1090
  * @param name - Project name (auto-detected from cwd if not provided)
605
- * @param projectPath - Project path (defaults to cwd)
1091
+ * @param projectPath - Project path (defaults to cwd, used for local status checks)
606
1092
  * @returns Project status or null if not found
607
1093
  */
608
1094
  export async function getProjectStatus(
@@ -610,11 +1096,12 @@ export async function getProjectStatus(
610
1096
  projectPath?: string,
611
1097
  ): Promise<ProjectStatus | null> {
612
1098
  let projectName = name;
1099
+ const resolvedPath = projectPath ?? process.cwd();
613
1100
 
614
1101
  // If no name provided, try to get from project path or cwd
615
1102
  if (!projectName) {
616
1103
  try {
617
- projectName = await getProjectNameFromDir(projectPath ?? process.cwd());
1104
+ projectName = await getProjectNameFromDir(resolvedPath);
618
1105
  } catch {
619
1106
  // Could not determine project name
620
1107
  return null;
@@ -627,8 +1114,15 @@ export async function getProjectStatus(
627
1114
  return null;
628
1115
  }
629
1116
 
630
- // Check actual status
631
- const localExists = project.localPath ? existsSync(project.localPath) : false;
1117
+ // Check if local project exists at the resolved path
1118
+ const hasWranglerConfig =
1119
+ existsSync(join(resolvedPath, "wrangler.jsonc")) ||
1120
+ existsSync(join(resolvedPath, "wrangler.toml")) ||
1121
+ existsSync(join(resolvedPath, "wrangler.json"));
1122
+ const localExists = hasWranglerConfig;
1123
+ const localPath = localExists ? resolvedPath : null;
1124
+
1125
+ // Check actual deployment status
632
1126
  const [workerExists, manifest] = await Promise.all([
633
1127
  checkWorkerExists(projectName),
634
1128
  getRemoteManifest(projectName),
@@ -637,113 +1131,54 @@ export async function getProjectStatus(
637
1131
  const backupFiles = manifest ? manifest.files.length : null;
638
1132
  const backupLastSync = manifest ? manifest.lastSync : null;
639
1133
 
1134
+ // Get database name on-demand
1135
+ let dbName: string | null = null;
1136
+ if (project.deploy_mode === "managed" && project.remote?.project_id) {
1137
+ // For managed projects, fetch from control plane
1138
+ try {
1139
+ const { fetchProjectResources } = await import("./control-plane.ts");
1140
+ const resources = await fetchProjectResources(project.remote.project_id);
1141
+ const d1 = resources.find((r) => r.resource_type === "d1");
1142
+ dbName = d1?.resource_name || null;
1143
+ } catch {
1144
+ // Ignore errors, dbName stays null
1145
+ }
1146
+ } else if (localExists) {
1147
+ // For BYO, parse from wrangler config
1148
+ try {
1149
+ const { parseWranglerResources } = await import("./resources.ts");
1150
+ const resources = await parseWranglerResources(resolvedPath);
1151
+ dbName = resources.d1?.name || null;
1152
+ } catch {
1153
+ // Ignore errors, dbName stays null
1154
+ }
1155
+ }
1156
+
640
1157
  return {
641
1158
  name: projectName,
642
- localPath: project.localPath,
1159
+ localPath,
643
1160
  workerUrl: project.workerUrl,
644
1161
  lastDeployed: project.lastDeployed,
645
1162
  createdAt: project.createdAt,
646
- accountId: project.cloudflare.accountId,
647
- workerId: project.cloudflare.workerId,
648
- dbName: getProjectDatabaseName(project),
1163
+ accountId: project.cloudflare?.accountId ?? null,
1164
+ workerId: project.cloudflare?.workerId ?? null,
1165
+ dbName,
649
1166
  deployed: workerExists || !!project.workerUrl,
650
1167
  local: localExists,
651
1168
  backedUp,
652
- missing: project.localPath ? !localExists : false,
1169
+ missing: false, // No longer tracking local paths in registry
653
1170
  backupFiles,
654
1171
  backupLastSync,
655
1172
  };
656
1173
  }
657
1174
 
658
- // ============================================================================
659
- // List All Projects Operation
660
- // ============================================================================
661
-
662
- /**
663
- * List all projects with status information
664
- *
665
- * Extracted from commands/projects.ts listProjects to enable programmatic project listing.
666
- *
667
- * @param filter - Filter projects by status
668
- * @returns Array of project statuses
669
- */
670
- export async function listAllProjects(
671
- filter?: "all" | "local" | "deployed" | "cloud",
672
- ): Promise<ProjectStatus[]> {
673
- const projects = await getAllProjects();
674
- const projectNames = Object.keys(projects);
675
-
676
- if (projectNames.length === 0) {
677
- return [];
678
- }
679
-
680
- // Determine status for each project
681
- const statuses: ProjectStatus[] = await Promise.all(
682
- projectNames.map(async (name) => {
683
- const project = projects[name];
684
- if (!project) {
685
- return null;
686
- }
687
-
688
- const local = project.localPath ? existsSync(project.localPath) : false;
689
- const missing = project.localPath ? !local : false;
690
-
691
- // Check if deployed
692
- let deployed = false;
693
- if (project.workerUrl) {
694
- deployed = true;
695
- } else {
696
- deployed = await checkWorkerExists(name);
697
- }
698
-
699
- // Check if backed up
700
- const manifest = await getRemoteManifest(name);
701
- const backedUp = manifest !== null;
702
- const backupFiles = manifest ? manifest.files.length : null;
703
- const backupLastSync = manifest ? manifest.lastSync : null;
704
-
705
- return {
706
- name,
707
- localPath: project.localPath,
708
- workerUrl: project.workerUrl,
709
- lastDeployed: project.lastDeployed,
710
- createdAt: project.createdAt,
711
- accountId: project.cloudflare.accountId,
712
- workerId: project.cloudflare.workerId,
713
- dbName: getProjectDatabaseName(project),
714
- local,
715
- deployed,
716
- backedUp,
717
- missing,
718
- backupFiles,
719
- backupLastSync,
720
- };
721
- }),
722
- ).then((results) => results.filter((s): s is ProjectStatus => s !== null));
723
-
724
- // Apply filter
725
- if (!filter || filter === "all") {
726
- return statuses;
727
- }
728
-
729
- switch (filter) {
730
- case "local":
731
- return statuses.filter((s) => s.local);
732
- case "deployed":
733
- return statuses.filter((s) => s.deployed);
734
- case "cloud":
735
- return statuses.filter((s) => s.backedUp);
736
- default:
737
- return statuses;
738
- }
739
- }
740
-
741
1175
  // ============================================================================
742
1176
  // Cleanup Operations
743
1177
  // ============================================================================
744
1178
 
745
1179
  /**
746
- * Scan registry for stale projects
1180
+ * Scan registry for stale projects.
1181
+ * Checks for projects with worker URLs that no longer have deployed workers.
747
1182
  * Returns total project count and stale entries with reasons.
748
1183
  */
749
1184
  export async function scanStaleProjects(): Promise<StaleProjectScan> {
@@ -755,21 +1190,13 @@ export async function scanStaleProjects(): Promise<StaleProjectScan> {
755
1190
  const project = projects[name];
756
1191
  if (!project) continue;
757
1192
 
758
- if (project.localPath && !existsSync(project.localPath)) {
759
- stale.push({
760
- name,
761
- reason: "local folder deleted",
762
- workerUrl: project.workerUrl,
763
- });
764
- continue;
765
- }
766
-
1193
+ // Check if worker URL is set but worker doesn't exist
767
1194
  if (project.workerUrl) {
768
1195
  const workerExists = await checkWorkerExists(name);
769
1196
  if (!workerExists) {
770
1197
  stale.push({
771
1198
  name,
772
- reason: "undeployed from cloud",
1199
+ reason: "worker not deployed",
773
1200
  workerUrl: project.workerUrl,
774
1201
  });
775
1202
  }