@getjack/jack 0.1.0 → 0.1.1

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.
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Core project operations library for jack CLI
3
+ *
4
+ * This module extracts reusable business logic from CLI commands
5
+ * to enable integration with MCP tools and other programmatic interfaces.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { $ } from "bun";
11
+ import { renderTemplate, resolveTemplate } from "../templates/index.ts";
12
+ import type { Template } from "../templates/types.ts";
13
+ 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";
17
+ import { getSyncConfig } from "./config.ts";
18
+ import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
19
+ import { JackError, JackErrorCode } from "./errors.ts";
20
+ import { type HookOutput, runHook } from "./hooks.ts";
21
+ import { generateProjectName } from "./names.ts";
22
+ import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
23
+ import {
24
+ getAllProjects,
25
+ getProject,
26
+ getProjectDatabaseName,
27
+ registerProject,
28
+ removeProject,
29
+ } from "./registry.ts";
30
+ import { applySchema, getD1DatabaseName, hasD1Config } from "./schema.ts";
31
+ import { getSavedSecrets } from "./secrets.ts";
32
+ import { getProjectNameFromDir, getRemoteManifest, syncToCloud } from "./storage/index.ts";
33
+
34
+ // ============================================================================
35
+ // Type Definitions
36
+ // ============================================================================
37
+
38
+ export interface CreateProjectOptions {
39
+ template?: string;
40
+ reporter?: OperationReporter;
41
+ interactive?: boolean;
42
+ }
43
+
44
+ export interface CreateProjectResult {
45
+ projectName: string;
46
+ targetDir: string;
47
+ workerUrl: string | null;
48
+ }
49
+
50
+ export interface DeployOptions {
51
+ projectPath?: string;
52
+ reporter?: OperationReporter;
53
+ interactive?: boolean;
54
+ includeSecrets?: boolean;
55
+ includeSync?: boolean;
56
+ }
57
+
58
+ export interface DeployResult {
59
+ workerUrl: string | null;
60
+ projectName: string;
61
+ deployOutput?: string;
62
+ }
63
+
64
+ export interface ProjectStatus {
65
+ name: string;
66
+ localPath: string | null;
67
+ workerUrl: string | null;
68
+ lastDeployed: string | null;
69
+ createdAt: string | null;
70
+ accountId: string | null;
71
+ workerId: string | null;
72
+ dbName: string | null;
73
+ deployed: boolean;
74
+ local: boolean;
75
+ backedUp: boolean;
76
+ missing: boolean;
77
+ backupFiles: number | null;
78
+ backupLastSync: string | null;
79
+ }
80
+
81
+ export interface StaleProject {
82
+ name: string;
83
+ reason: "local folder deleted" | "undeployed from cloud";
84
+ workerUrl: string | null;
85
+ }
86
+
87
+ export interface StaleProjectScan {
88
+ total: number;
89
+ stale: StaleProject[];
90
+ }
91
+
92
+ export interface OperationSpinner {
93
+ success(message: string): void;
94
+ error(message: string): void;
95
+ stop(): void;
96
+ }
97
+
98
+ export interface OperationReporter extends HookOutput {
99
+ start(message: string): void;
100
+ stop(): void;
101
+ spinner(message: string): OperationSpinner;
102
+ }
103
+
104
+ const noopSpinner: OperationSpinner = {
105
+ success() {},
106
+ error() {},
107
+ stop() {},
108
+ };
109
+
110
+ const noopReporter: OperationReporter = {
111
+ start() {},
112
+ stop() {},
113
+ spinner() {
114
+ return noopSpinner;
115
+ },
116
+ info() {},
117
+ warn() {},
118
+ error() {},
119
+ success() {},
120
+ box() {},
121
+ };
122
+
123
+ // ============================================================================
124
+ // Create Project Operation
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Create a new project from a template
129
+ *
130
+ * Extracted from commands/new.ts to enable programmatic project creation.
131
+ *
132
+ * @param name - Project name (auto-generated if not provided)
133
+ * @param options - Creation options
134
+ * @returns Project creation result with name, path, and deployment URL
135
+ * @throws Error if initialization check fails, directory exists, or template issues occur
136
+ */
137
+ export async function createProject(
138
+ name?: string,
139
+ options: CreateProjectOptions = {},
140
+ ): Promise<CreateProjectResult> {
141
+ const { template: templateOption, reporter: providedReporter, interactive: interactiveOption } =
142
+ options;
143
+ const reporter = providedReporter ?? noopReporter;
144
+ const hasReporter = Boolean(providedReporter);
145
+ const isCi = process.env.CI === "true" || process.env.CI === "1";
146
+ const interactive = interactiveOption ?? !isCi;
147
+
148
+ // Check if jack init was run (throws if not)
149
+ const { isInitialized } = await import("../commands/init.ts");
150
+ const initialized = await isInitialized();
151
+ if (!initialized) {
152
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "jack is not set up yet", "Run: jack init");
153
+ }
154
+
155
+ // Generate or use provided name
156
+ const projectName = name ?? generateProjectName();
157
+ const targetDir = resolve(projectName);
158
+
159
+ // Check directory doesn't exist
160
+ if (existsSync(targetDir)) {
161
+ throw new JackError(
162
+ JackErrorCode.VALIDATION_ERROR,
163
+ `Directory ${projectName} already exists`,
164
+ );
165
+ }
166
+
167
+ reporter.start("Creating project...");
168
+
169
+ // Load template
170
+ let template: Template;
171
+ try {
172
+ template = await resolveTemplate(templateOption);
173
+ } catch (err) {
174
+ reporter.stop();
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ throw new JackError(JackErrorCode.TEMPLATE_NOT_FOUND, message);
177
+ }
178
+
179
+ const rendered = renderTemplate(template, { name: projectName });
180
+
181
+ // Handle template-specific secrets
182
+ const secretsToUse: Record<string, string> = {};
183
+ if (template.secrets?.length) {
184
+ const saved = await getSavedSecrets();
185
+
186
+ for (const key of template.secrets) {
187
+ if (saved[key]) {
188
+ secretsToUse[key] = saved[key];
189
+ }
190
+ }
191
+
192
+ const missing = template.secrets.filter((key) => !saved[key]);
193
+ if (missing.length > 0) {
194
+ reporter.stop();
195
+ const missingList = missing.join(", ");
196
+ throw new JackError(
197
+ JackErrorCode.VALIDATION_ERROR,
198
+ `Missing required secrets: ${missingList}`,
199
+ "Run: jack secrets add <key>",
200
+ { missingSecrets: missing },
201
+ );
202
+ }
203
+
204
+ reporter.stop();
205
+ for (const key of Object.keys(secretsToUse)) {
206
+ reporter.success(`Using saved secret: ${key}`);
207
+ }
208
+ reporter.start("Creating project...");
209
+ }
210
+
211
+ // Write all template files
212
+ for (const [filePath, content] of Object.entries(rendered.files)) {
213
+ await Bun.write(join(targetDir, filePath), content);
214
+ }
215
+
216
+ // Write secrets files (.env for Vite, .dev.vars for wrangler local, .secrets.json for wrangler bulk)
217
+ if (Object.keys(secretsToUse).length > 0) {
218
+ const envContent = generateEnvFile(secretsToUse);
219
+ const jsonContent = generateSecretsJson(secretsToUse);
220
+ await Bun.write(join(targetDir, ".env"), envContent);
221
+ await Bun.write(join(targetDir, ".dev.vars"), envContent);
222
+ await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
223
+
224
+ const gitignorePath = join(targetDir, ".gitignore");
225
+ const gitignoreExists = existsSync(gitignorePath);
226
+
227
+ if (!gitignoreExists) {
228
+ await Bun.write(gitignorePath, ".env\n.env.*\n.dev.vars\n.secrets.json\nnode_modules/\n");
229
+ } else {
230
+ const existingContent = await Bun.file(gitignorePath).text();
231
+ if (!existingContent.includes(".env")) {
232
+ await Bun.write(
233
+ gitignorePath,
234
+ `${existingContent}\n.env\n.env.*\n.dev.vars\n.secrets.json\n`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+
240
+ // Generate agent context files
241
+ let activeAgents = await getActiveAgents();
242
+ if (activeAgents.length > 0) {
243
+ const validation = await validateAgentPaths();
244
+
245
+ if (validation.invalid.length > 0) {
246
+ reporter.stop();
247
+ reporter.warn("Some agent paths no longer exist:");
248
+ for (const { id, path } of validation.invalid) {
249
+ reporter.info(` ${id}: ${path}`);
250
+ }
251
+ reporter.info("Run: jack agents scan");
252
+ reporter.start("Creating project...");
253
+
254
+ // Filter out invalid agents
255
+ activeAgents = activeAgents.filter(
256
+ ({ id }) => !validation.invalid.some((inv) => inv.id === id),
257
+ );
258
+ }
259
+
260
+ if (activeAgents.length > 0) {
261
+ await generateAgentFiles(targetDir, projectName, template, activeAgents);
262
+ const agentNames = activeAgents.map(({ definition }) => definition.name).join(", ");
263
+ reporter.stop();
264
+ reporter.success(`Generated context for: ${agentNames}`);
265
+ reporter.start("Creating project...");
266
+ }
267
+ }
268
+
269
+ reporter.stop();
270
+ reporter.success(`Created ${projectName}/`);
271
+
272
+ // Auto-install dependencies
273
+ reporter.start("Installing dependencies...");
274
+
275
+ const install = Bun.spawn(["bun", "install"], {
276
+ cwd: targetDir,
277
+ stdout: "ignore",
278
+ stderr: "ignore",
279
+ });
280
+ await install.exited;
281
+
282
+ if (install.exitCode !== 0) {
283
+ reporter.stop();
284
+ reporter.warn("Failed to install dependencies, run: bun install");
285
+ throw new JackError(
286
+ JackErrorCode.BUILD_FAILED,
287
+ "Dependency installation failed",
288
+ "Run: bun install",
289
+ { exitCode: 0, reported: hasReporter },
290
+ );
291
+ }
292
+
293
+ reporter.stop();
294
+ reporter.success("Dependencies installed");
295
+
296
+ // Run pre-deploy hooks
297
+ if (template.hooks?.preDeploy?.length) {
298
+ const hookContext = { projectName, projectDir: targetDir };
299
+ const passed = await runHook(template.hooks.preDeploy, hookContext, {
300
+ interactive,
301
+ output: reporter,
302
+ });
303
+ if (!passed) {
304
+ reporter.error("Pre-deploy checks failed");
305
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "Pre-deploy checks failed", undefined, {
306
+ exitCode: 0,
307
+ reported: hasReporter,
308
+ });
309
+ }
310
+ }
311
+
312
+ // For Vite projects, build first
313
+ const hasVite = existsSync(join(targetDir, "vite.config.ts"));
314
+ if (hasVite) {
315
+ reporter.start("Building...");
316
+
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
+ );
327
+ }
328
+
329
+ reporter.stop();
330
+ reporter.success("Built");
331
+ }
332
+
333
+ // Deploy
334
+ reporter.start("Deploying...");
335
+
336
+ const deployResult = await $`wrangler deploy`.cwd(targetDir).nothrow().quiet();
337
+
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
+ }
348
+
349
+ // Apply schema.sql after deploy
350
+ if (await hasD1Config(targetDir)) {
351
+ const dbName = await getD1DatabaseName(targetDir);
352
+ if (dbName) {
353
+ try {
354
+ await applySchema(dbName, targetDir);
355
+ } catch (err) {
356
+ reporter.warn(`Schema application failed: ${err}`);
357
+ reporter.info("Run manually: bun run db:migrate");
358
+ }
359
+ }
360
+ }
361
+
362
+ // Push secrets to Cloudflare
363
+ const secretsJsonPath = join(targetDir, ".secrets.json");
364
+ if (existsSync(secretsJsonPath)) {
365
+ reporter.start("Configuring secrets...");
366
+
367
+ const secretsResult = await $`wrangler secret bulk .secrets.json`
368
+ .cwd(targetDir)
369
+ .nothrow()
370
+ .quiet();
371
+
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 {
377
+ reporter.stop();
378
+ reporter.success("Secrets configured");
379
+ }
380
+ }
381
+
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;
386
+
387
+ reporter.stop();
388
+ if (workerUrl) {
389
+ reporter.success(`Live: ${workerUrl}`);
390
+ } else {
391
+ reporter.success("Deployed");
392
+ }
393
+
394
+ // Register project in registry
395
+ try {
396
+ const accountId = await getAccountId();
397
+ const dbName = await getD1DatabaseName(targetDir);
398
+
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,
411
+ },
412
+ },
413
+ });
414
+ } catch {
415
+ // Don't fail the creation if registry update fails
416
+ }
417
+
418
+ // Run post-deploy hooks
419
+ if (template.hooks?.postDeploy?.length && workerUrl) {
420
+ const domain = workerUrl.replace(/^https?:\/\//, "");
421
+ await runHook(
422
+ template.hooks.postDeploy,
423
+ {
424
+ domain,
425
+ url: workerUrl,
426
+ projectName,
427
+ projectDir: targetDir,
428
+ },
429
+ { interactive, output: reporter },
430
+ );
431
+ }
432
+
433
+ return {
434
+ projectName,
435
+ targetDir,
436
+ workerUrl,
437
+ };
438
+ }
439
+
440
+ // ============================================================================
441
+ // Deploy Project Operation
442
+ // ============================================================================
443
+
444
+ /**
445
+ * Deploy an existing project
446
+ *
447
+ * Extracted from commands/ship.ts to enable programmatic deployment.
448
+ *
449
+ * @param options - Deployment options
450
+ * @returns Deployment result with URL and project name
451
+ * @throws Error if no wrangler config found, build fails, or deploy fails
452
+ */
453
+ export async function deployProject(options: DeployOptions = {}): Promise<DeployResult> {
454
+ const {
455
+ projectPath = process.cwd(),
456
+ reporter: providedReporter,
457
+ interactive: interactiveOption,
458
+ includeSecrets = false,
459
+ includeSync = false,
460
+ } = options;
461
+ const reporter = providedReporter ?? noopReporter;
462
+ const hasReporter = Boolean(providedReporter);
463
+ const isCi = process.env.CI === "true" || process.env.CI === "1";
464
+ const interactive = interactiveOption ?? !isCi;
465
+
466
+ // Check for wrangler config
467
+ const hasWranglerConfig =
468
+ existsSync(join(projectPath, "wrangler.toml")) ||
469
+ existsSync(join(projectPath, "wrangler.jsonc")) ||
470
+ existsSync(join(projectPath, "wrangler.json"));
471
+
472
+ if (!hasWranglerConfig) {
473
+ throw new JackError(
474
+ JackErrorCode.PROJECT_NOT_FOUND,
475
+ "No wrangler config found in current directory",
476
+ "Run: jack new <project-name>",
477
+ );
478
+ }
479
+
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"));
485
+
486
+ if (isViteProject) {
487
+ const buildSpin = reporter.spinner("Building...");
488
+ const buildResult = await $`npx vite build`.cwd(projectPath).nothrow().quiet();
489
+
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");
499
+ }
500
+
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
+ });
512
+ }
513
+
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);
519
+
520
+ if (workerUrl) {
521
+ spin.success(`Live: ${workerUrl}`);
522
+ } else {
523
+ spin.success("Deployed");
524
+ }
525
+
526
+ // Apply schema if needed
527
+ let dbName: string | null = null;
528
+ if (await hasD1Config(projectPath)) {
529
+ dbName = await getD1DatabaseName(projectPath);
530
+ if (dbName) {
531
+ try {
532
+ await applySchema(dbName, projectPath);
533
+ } catch (err) {
534
+ reporter.warn(`Schema application failed: ${err}`);
535
+ reporter.info("Run manually: bun run db:migrate");
536
+ }
537
+ }
538
+ }
539
+
540
+ // Update registry
541
+ try {
542
+ await registerProject(projectName, {
543
+ localPath: projectPath,
544
+ workerUrl,
545
+ lastDeployed: new Date().toISOString(),
546
+ resources: {
547
+ services: {
548
+ db: dbName,
549
+ },
550
+ },
551
+ });
552
+ } catch {
553
+ // Don't fail the deploy if registry update fails
554
+ }
555
+
556
+ if (includeSecrets && interactive) {
557
+ const detected = await detectSecrets(projectPath);
558
+ const newSecrets = await filterNewSecrets(detected);
559
+
560
+ if (newSecrets.length > 0) {
561
+ await promptSaveSecrets(newSecrets);
562
+ }
563
+ }
564
+
565
+ if (includeSync) {
566
+ const syncConfig = await getSyncConfig();
567
+ if (syncConfig.enabled && syncConfig.autoSync) {
568
+ const syncSpin = reporter.spinner("Syncing source to cloud...");
569
+ try {
570
+ const syncResult = await syncToCloud(projectPath);
571
+ if (syncResult.success) {
572
+ if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
573
+ syncSpin.success(
574
+ `Backed up ${syncResult.filesUploaded} files to jack-storage/${projectName}/`,
575
+ );
576
+ } else {
577
+ syncSpin.success("Source already synced");
578
+ }
579
+ }
580
+ } catch {
581
+ syncSpin.stop();
582
+ reporter.warn("Cloud sync failed (deploy succeeded)");
583
+ reporter.info("Run: jack sync");
584
+ }
585
+ }
586
+ }
587
+
588
+ return {
589
+ workerUrl,
590
+ projectName,
591
+ deployOutput: workerUrl ? undefined : deployOutput,
592
+ };
593
+ }
594
+
595
+ // ============================================================================
596
+ // Get Project Status Operation
597
+ // ============================================================================
598
+
599
+ /**
600
+ * Get detailed status for a specific project
601
+ *
602
+ * Extracted from commands/projects.ts infoProject to enable programmatic status checks.
603
+ *
604
+ * @param name - Project name (auto-detected from cwd if not provided)
605
+ * @param projectPath - Project path (defaults to cwd)
606
+ * @returns Project status or null if not found
607
+ */
608
+ export async function getProjectStatus(
609
+ name?: string,
610
+ projectPath?: string,
611
+ ): Promise<ProjectStatus | null> {
612
+ let projectName = name;
613
+
614
+ // If no name provided, try to get from project path or cwd
615
+ if (!projectName) {
616
+ try {
617
+ projectName = await getProjectNameFromDir(projectPath ?? process.cwd());
618
+ } catch {
619
+ // Could not determine project name
620
+ return null;
621
+ }
622
+ }
623
+
624
+ const project = await getProject(projectName);
625
+
626
+ if (!project) {
627
+ return null;
628
+ }
629
+
630
+ // Check actual status
631
+ const localExists = project.localPath ? existsSync(project.localPath) : false;
632
+ const [workerExists, manifest] = await Promise.all([
633
+ checkWorkerExists(projectName),
634
+ getRemoteManifest(projectName),
635
+ ]);
636
+ const backedUp = manifest !== null;
637
+ const backupFiles = manifest ? manifest.files.length : null;
638
+ const backupLastSync = manifest ? manifest.lastSync : null;
639
+
640
+ return {
641
+ name: projectName,
642
+ localPath: project.localPath,
643
+ workerUrl: project.workerUrl,
644
+ lastDeployed: project.lastDeployed,
645
+ createdAt: project.createdAt,
646
+ accountId: project.cloudflare.accountId,
647
+ workerId: project.cloudflare.workerId,
648
+ dbName: getProjectDatabaseName(project),
649
+ deployed: workerExists || !!project.workerUrl,
650
+ local: localExists,
651
+ backedUp,
652
+ missing: project.localPath ? !localExists : false,
653
+ backupFiles,
654
+ backupLastSync,
655
+ };
656
+ }
657
+
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
+ // ============================================================================
742
+ // Cleanup Operations
743
+ // ============================================================================
744
+
745
+ /**
746
+ * Scan registry for stale projects
747
+ * Returns total project count and stale entries with reasons.
748
+ */
749
+ export async function scanStaleProjects(): Promise<StaleProjectScan> {
750
+ const projects = await getAllProjects();
751
+ const projectNames = Object.keys(projects);
752
+ const stale: StaleProject[] = [];
753
+
754
+ for (const name of projectNames) {
755
+ const project = projects[name];
756
+ if (!project) continue;
757
+
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
+
767
+ if (project.workerUrl) {
768
+ const workerExists = await checkWorkerExists(name);
769
+ if (!workerExists) {
770
+ stale.push({
771
+ name,
772
+ reason: "undeployed from cloud",
773
+ workerUrl: project.workerUrl,
774
+ });
775
+ }
776
+ }
777
+ }
778
+
779
+ return { total: projectNames.length, stale };
780
+ }
781
+
782
+ /**
783
+ * Remove stale registry entries by name
784
+ * Returns the number of entries removed.
785
+ */
786
+ export async function cleanupStaleProjects(names: string[]): Promise<number> {
787
+ let removed = 0;
788
+ for (const name of names) {
789
+ await removeProject(name);
790
+ removed += 1;
791
+ }
792
+ return removed;
793
+ }