@getjack/jack 0.1.2 → 0.1.3

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/package.json +54 -47
  2. package/src/commands/agents.ts +145 -10
  3. package/src/commands/down.ts +110 -102
  4. package/src/commands/feedback.ts +189 -0
  5. package/src/commands/init.ts +8 -12
  6. package/src/commands/login.ts +88 -0
  7. package/src/commands/logout.ts +14 -0
  8. package/src/commands/logs.ts +21 -0
  9. package/src/commands/mcp.ts +134 -7
  10. package/src/commands/new.ts +43 -17
  11. package/src/commands/open.ts +13 -6
  12. package/src/commands/projects.ts +269 -143
  13. package/src/commands/secrets.ts +413 -0
  14. package/src/commands/services.ts +96 -123
  15. package/src/commands/ship.ts +5 -1
  16. package/src/commands/whoami.ts +31 -0
  17. package/src/index.ts +218 -144
  18. package/src/lib/agent-files.ts +34 -0
  19. package/src/lib/agents.ts +390 -22
  20. package/src/lib/asset-hash.ts +50 -0
  21. package/src/lib/auth/client.ts +115 -0
  22. package/src/lib/auth/constants.ts +5 -0
  23. package/src/lib/auth/guard.ts +57 -0
  24. package/src/lib/auth/index.ts +18 -0
  25. package/src/lib/auth/store.ts +54 -0
  26. package/src/lib/binding-validator.ts +136 -0
  27. package/src/lib/build-helper.ts +211 -0
  28. package/src/lib/cloudflare-api.ts +24 -0
  29. package/src/lib/config.ts +5 -6
  30. package/src/lib/control-plane.ts +295 -0
  31. package/src/lib/debug.ts +3 -1
  32. package/src/lib/deploy-mode.ts +93 -0
  33. package/src/lib/deploy-upload.ts +92 -0
  34. package/src/lib/errors.ts +2 -0
  35. package/src/lib/github.ts +31 -1
  36. package/src/lib/hooks.ts +4 -12
  37. package/src/lib/intent.ts +88 -0
  38. package/src/lib/jsonc.ts +125 -0
  39. package/src/lib/local-paths.test.ts +902 -0
  40. package/src/lib/local-paths.ts +258 -0
  41. package/src/lib/managed-deploy.ts +175 -0
  42. package/src/lib/managed-down.ts +159 -0
  43. package/src/lib/mcp-config.ts +55 -34
  44. package/src/lib/names.ts +9 -29
  45. package/src/lib/project-operations.ts +676 -249
  46. package/src/lib/project-resolver.ts +476 -0
  47. package/src/lib/registry.ts +76 -37
  48. package/src/lib/resources.ts +196 -0
  49. package/src/lib/schema.ts +30 -1
  50. package/src/lib/storage/file-filter.ts +1 -0
  51. package/src/lib/storage/index.ts +5 -1
  52. package/src/lib/telemetry.ts +14 -0
  53. package/src/lib/tty.ts +15 -0
  54. package/src/lib/zip-packager.ts +255 -0
  55. package/src/mcp/resources/index.ts +8 -2
  56. package/src/mcp/server.ts +32 -4
  57. package/src/mcp/tools/index.ts +35 -13
  58. package/src/mcp/types.ts +6 -0
  59. package/src/mcp/utils.ts +1 -1
  60. package/src/templates/index.ts +42 -4
  61. package/src/templates/types.ts +13 -0
  62. package/templates/CLAUDE.md +166 -0
  63. package/templates/api/.jack.json +4 -0
  64. package/templates/api/bun.lock +1 -0
  65. package/templates/api/wrangler.jsonc +5 -0
  66. package/templates/hello/.jack.json +28 -0
  67. package/templates/hello/package.json +10 -0
  68. package/templates/hello/src/index.ts +11 -0
  69. package/templates/hello/tsconfig.json +11 -0
  70. package/templates/hello/wrangler.jsonc +5 -0
  71. package/templates/miniapp/.jack.json +15 -4
  72. package/templates/miniapp/bun.lock +135 -40
  73. package/templates/miniapp/index.html +1 -0
  74. package/templates/miniapp/package.json +3 -1
  75. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  76. package/templates/miniapp/public/icon.png +0 -0
  77. package/templates/miniapp/public/og.png +0 -0
  78. package/templates/miniapp/schema.sql +8 -0
  79. package/templates/miniapp/src/App.tsx +254 -3
  80. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  81. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  82. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  83. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  84. package/templates/miniapp/src/index.css +15 -0
  85. package/templates/miniapp/src/lib/api.ts +2 -1
  86. package/templates/miniapp/src/worker.ts +515 -1
  87. package/templates/miniapp/wrangler.jsonc +15 -3
  88. package/LICENSE +0 -190
  89. package/README.md +0 -55
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
@@ -1,13 +1,19 @@
1
- import { dirname } from "node:path";
2
- import { select } from "@inquirer/prompts";
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { promptSelect } from "../lib/hooks.ts";
3
5
  import { error, info, item, output as outputSpinner, success, warn } from "../lib/output.ts";
4
6
  import {
5
7
  cleanupStaleProjects,
6
8
  getProjectStatus,
7
- listAllProjects,
8
9
  scanStaleProjects,
9
- type ProjectStatus,
10
10
  } from "../lib/project-operations.ts";
11
+ import {
12
+ type ResolvedProject,
13
+ listAllProjects,
14
+ removeProject as removeProjectEverywhere,
15
+ resolveProject,
16
+ } from "../lib/project-resolver.ts";
11
17
  import { getProjectNameFromDir } from "../lib/storage/index.ts";
12
18
 
13
19
  /**
@@ -21,87 +27,85 @@ export default async function projects(subcommand?: string, args: string[] = [])
21
27
  switch (subcommand) {
22
28
  case "info":
23
29
  return await infoProject(args);
30
+ case "remove":
31
+ return await removeProjectEntry(args);
24
32
  case "cleanup":
25
33
  return await cleanupProjects();
26
- default:
34
+ case "scan":
35
+ return await scanProjects(args);
36
+ case "down":
37
+ return await handleDown(args);
38
+ default: {
39
+ // Commands that are valid top-level commands users might try under projects
40
+ const topLevelCommands = ["open", "logs", "clone", "sync"];
41
+ const isTopLevel = topLevelCommands.includes(subcommand);
42
+
27
43
  error(`Unknown subcommand: ${subcommand}`);
28
- info("Available: list, info, cleanup");
44
+ if (isTopLevel) {
45
+ const projectArg = args[0] ? ` ${args[0]}` : "";
46
+ info(`Did you mean: jack ${subcommand}${projectArg}`);
47
+ }
48
+ info("Available: list, info, remove, cleanup, scan, down");
29
49
  process.exit(1);
50
+ }
30
51
  }
31
52
  }
32
53
 
33
54
  /**
34
55
  * List all projects with status indicators
35
56
  */
36
- async function listProjects(args: string[]): Promise<void> {
37
- // Parse flags
38
- const flags = {
39
- local: args.includes("--local"),
40
- deployed: args.includes("--deployed"),
41
- cloud: args.includes("--cloud"),
42
- };
43
-
44
- // Determine status for each project (with spinner for API calls)
57
+ async function listProjects(_args: string[]): Promise<void> {
58
+ // Fetch all projects from registry and control plane
45
59
  outputSpinner.start("Checking project status...");
46
- const statuses: ProjectStatus[] = await listAllProjects();
60
+ const projects: ResolvedProject[] = await listAllProjects();
47
61
  outputSpinner.stop();
48
62
 
49
- if (statuses.length === 0) {
63
+ if (projects.length === 0) {
50
64
  info("No projects found");
51
65
  info("Create a project with: jack new <name>");
52
66
  return;
53
67
  }
54
68
 
55
- // Filter based on flags
56
- let filteredStatuses = statuses;
57
- if (flags.local) {
58
- filteredStatuses = filteredStatuses.filter((s) => s.local);
59
- }
60
- if (flags.deployed) {
61
- filteredStatuses = filteredStatuses.filter((s) => s.deployed);
62
- }
63
- if (flags.cloud) {
64
- filteredStatuses = filteredStatuses.filter((s) => s.backedUp);
65
- }
69
+ // Separate local projects from cloud-only projects
70
+ const localProjects: ResolvedProject[] = [];
71
+ const cloudOnlyProjects: ResolvedProject[] = [];
66
72
 
67
- if (filteredStatuses.length === 0) {
68
- info("No projects match the specified filters");
69
- return;
73
+ for (const proj of projects) {
74
+ if (proj.localPath && proj.sources.filesystem) {
75
+ localProjects.push(proj);
76
+ } else {
77
+ cloudOnlyProjects.push(proj);
78
+ }
70
79
  }
71
80
 
72
- // Group projects by parent directory
81
+ // Group local projects by parent directory
73
82
  interface DirectoryGroup {
74
- path: string;
75
- projects: ProjectStatus[];
83
+ displayPath: string;
84
+ projects: ResolvedProject[];
76
85
  }
77
86
 
78
87
  const groups = new Map<string, DirectoryGroup>();
79
- const ungrouped: ProjectStatus[] = [];
80
- const stale: ProjectStatus[] = [];
81
-
82
- for (const status of filteredStatuses) {
83
- // Stale projects go to their own section
84
- if (status.missing) {
85
- stale.push(status);
86
- } else if (status.localPath && status.local) {
87
- const parent = dirname(status.localPath);
88
- if (!groups.has(parent)) {
89
- groups.set(parent, { path: parent, projects: [] });
90
- }
91
- groups.get(parent)?.projects.push(status);
92
- } else {
93
- ungrouped.push(status);
88
+ const home = homedir();
89
+
90
+ for (const proj of localProjects) {
91
+ if (!proj.localPath) continue;
92
+ const parent = dirname(proj.localPath);
93
+ if (!groups.has(parent)) {
94
+ // Replace home directory with ~ for display
95
+ const displayPath = parent.startsWith(home) ? `~${parent.slice(home.length)}` : parent;
96
+ groups.set(parent, { displayPath, projects: [] });
94
97
  }
98
+ groups.get(parent)?.projects.push(proj);
95
99
  }
96
100
 
97
- // Display grouped projects
101
+ // Display header
98
102
  console.error("");
99
- info("Projects");
103
+ info("Your projects");
100
104
  console.error("");
101
105
 
102
- // Display directory groups (active local projects)
106
+ // Display local project groups
103
107
  for (const [_parentPath, group] of groups) {
104
- console.error(`${colors.dim}${group.path}/${colors.reset}`);
108
+ console.error(` ${colors.dim}${group.displayPath}/${colors.reset}`);
105
109
  const sortedProjects = group.projects.sort((a, b) => a.name.localeCompare(b.name));
106
110
 
107
111
  for (let i = 0; i < sortedProjects.length; i++) {
@@ -110,51 +114,41 @@ async function listProjects(args: string[]): Promise<void> {
110
114
  const isLast = i === sortedProjects.length - 1;
111
115
  const prefix = isLast ? "└──" : "├──";
112
116
 
113
- const badges = buildStatusBadges(proj);
114
- console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${badges}`);
117
+ const statusBadge = buildStatusBadge(proj);
118
+ console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${statusBadge}`);
115
119
  }
116
120
  console.error("");
117
121
  }
118
122
 
119
- // Display ungrouped projects (cloud-only, no local path)
120
- if (ungrouped.length > 0) {
121
- console.error(`${colors.dim}Cloud only:${colors.reset}`);
122
- for (const proj of ungrouped) {
123
- const badges = buildStatusBadges(proj);
124
- console.error(` ${proj.name} ${badges}`);
125
- }
126
- console.error("");
127
- }
123
+ // Display cloud-only projects
124
+ if (cloudOnlyProjects.length > 0) {
125
+ console.error(` ${colors.dim}On jack cloud (no local files)${colors.reset}`);
126
+ const sortedCloudProjects = cloudOnlyProjects.sort((a, b) => a.name.localeCompare(b.name));
127
+
128
+ for (let i = 0; i < sortedCloudProjects.length; i++) {
129
+ const proj = sortedCloudProjects[i];
130
+ if (!proj) continue;
131
+ const isLast = i === sortedCloudProjects.length - 1;
132
+ const prefix = isLast ? "└──" : "├──";
128
133
 
129
- // Display stale projects (local folder deleted)
130
- if (stale.length > 0) {
131
- console.error(`${colors.yellow}Stale (local folder deleted):${colors.reset}`);
132
- for (const proj of stale) {
133
- // Only show non-missing badges since the section header explains the issue
134
- const badges = buildStatusBadges({ ...proj, missing: false });
135
- console.error(` ${colors.dim}${proj.name}${colors.reset} ${badges}`);
134
+ const statusBadge = buildStatusBadge(proj);
135
+ console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${statusBadge}`);
136
136
  }
137
137
  console.error("");
138
138
  }
139
139
 
140
140
  // Summary
141
- const deployedCount = statuses.filter((s) => s.deployed).length;
142
- const notDeployedCount = statuses.filter((s) => s.local && !s.deployed).length;
143
- const staleCount = statuses.filter((s) => s.missing).length;
141
+ const liveCount = projects.filter((p) => p.status === "live").length;
142
+ const localOnlyCount = projects.filter((p) => p.status === "local-only").length;
143
+ const errorCount = projects.filter((p) => p.status === "error").length;
144
144
 
145
- const parts = [`${deployedCount} deployed`];
146
- if (notDeployedCount > 0) {
147
- parts.push(`${notDeployedCount} not deployed`);
148
- }
149
- if (staleCount > 0) {
150
- parts.push(`${staleCount} stale`);
151
- }
152
- info(`${statuses.length} projects (${parts.join(", ")})`);
153
- if (staleCount > 0) {
154
- console.error(
155
- ` ${colors.dim}Run 'jack projects cleanup' to remove stale entries${colors.reset}`,
156
- );
157
- }
145
+ const parts: string[] = [];
146
+ if (liveCount > 0) parts.push(`${liveCount} live`);
147
+ if (localOnlyCount > 0) parts.push(`${localOnlyCount} local-only`);
148
+ if (errorCount > 0) parts.push(`${errorCount} error`);
149
+
150
+ const summary = parts.length > 0 ? ` (${parts.join(", ")})` : "";
151
+ info(`${projects.length} projects${summary}`);
158
152
  console.error("");
159
153
  }
160
154
 
@@ -169,25 +163,21 @@ const colors = {
169
163
  };
170
164
 
171
165
  /**
172
- * Build status badge string for a project
166
+ * Build a user-friendly status badge for a project
173
167
  */
174
- function buildStatusBadges(status: ProjectStatus): string {
175
- const badges: string[] = [];
176
-
177
- if (status.local) {
178
- badges.push(`${colors.green}[local]${colors.reset}`);
179
- }
180
- if (status.deployed) {
181
- badges.push(`${colors.green}[deployed]${colors.reset}`);
182
- }
183
- if (status.backedUp) {
184
- badges.push(`${colors.dim}[cloud]${colors.reset}`);
185
- }
186
- if (status.missing) {
187
- badges.push(`${colors.yellow}[local deleted]${colors.reset}`);
168
+ function buildStatusBadge(project: ResolvedProject): string {
169
+ switch (project.status) {
170
+ case "live":
171
+ return `${colors.green}[live]${colors.reset} ${colors.cyan}${project.url || ""}${colors.reset}`;
172
+ case "local-only":
173
+ return `${colors.dim}[local only]${colors.reset}`;
174
+ case "error":
175
+ return `${colors.red}[error]${colors.reset} ${project.errorMessage || "deployment failed"}`;
176
+ case "syncing":
177
+ return `${colors.yellow}[syncing]${colors.reset}`;
178
+ default:
179
+ return "";
188
180
  }
189
-
190
- return badges.join(" ");
191
181
  }
192
182
 
193
183
  /**
@@ -232,21 +222,15 @@ async function infoProject(args: string[]): Promise<void> {
232
222
  statuses.push("deployed");
233
223
  }
234
224
  if (status.backedUp) {
235
- statuses.push("backed-up");
236
- }
237
- if (status.missing) {
238
- statuses.push("missing");
225
+ statuses.push("backup");
239
226
  }
240
227
 
241
228
  item(`Status: ${statuses.join(", ") || "none"}`);
242
229
  console.error("");
243
230
 
244
- // Local info
231
+ // Workspace info (only shown if running from project directory)
245
232
  if (status.localPath) {
246
- item(`Local path: ${status.localPath}`);
247
- if (status.missing) {
248
- warn(" Path no longer exists");
249
- }
233
+ item(`Workspace path: ${status.localPath}`);
250
234
  console.error("");
251
235
  }
252
236
 
@@ -261,9 +245,9 @@ async function infoProject(args: string[]): Promise<void> {
261
245
  console.error("");
262
246
  }
263
247
 
264
- // Cloud info
248
+ // Backup info
265
249
  if (status.backedUp && status.backupFiles !== null) {
266
- item(`Cloud backup: ${status.backupFiles} files`);
250
+ item(`Backup: ${status.backupFiles} files`);
267
251
  if (status.backupLastSync) {
268
252
  item(`Last synced: ${new Date(status.backupLastSync).toLocaleString()}`);
269
253
  }
@@ -293,7 +277,7 @@ async function infoProject(args: string[]): Promise<void> {
293
277
  }
294
278
 
295
279
  /**
296
- * Remove stale registry entries
280
+ * Find and remove stale registry entries (projects with URLs but no deployed worker)
297
281
  */
298
282
  async function cleanupProjects(): Promise<void> {
299
283
  outputSpinner.start("Scanning for stale projects...");
@@ -316,42 +300,24 @@ async function cleanupProjects(): Promise<void> {
316
300
  console.error("");
317
301
  info("What cleanup does:");
318
302
  item("Removes entries from jack's local tracking registry");
319
- item("Does NOT undeploy live services");
320
- item("Does NOT delete cloud backups or databases");
303
+ item("Does NOT delete backups or databases");
304
+ info("Remove a single entry with: jack projects remove <name>");
321
305
  console.error("");
322
306
 
323
307
  // Show found issues
324
308
  warn(`Found ${scan.stale.length} stale project(s):`);
325
309
  console.error("");
326
310
 
327
- // Check which have deployed workers
328
- const deployedStale = scan.stale.filter((stale) => stale.workerUrl);
329
-
330
311
  for (const stale of scan.stale) {
331
- const hasWorker = stale.workerUrl
332
- ? ` ${colors.yellow}(still deployed)${colors.reset}`
333
- : "";
334
- item(`${stale.name}: ${stale.reason}${hasWorker}`);
312
+ item(`${stale.name}: ${stale.reason} (URL: ${stale.workerUrl})`);
335
313
  }
336
314
  console.error("");
337
315
 
338
- if (deployedStale.length > 0) {
339
- warn(`${deployedStale.length} project(s) are still deployed`);
340
- info("To fully remove, run 'jack down <name>' first");
341
- console.error("");
342
- }
343
-
344
316
  // Prompt to remove
345
- console.error(" Esc to skip\n");
346
- const action = await select({
347
- message: "Remove these from jack's tracking? (deployed services stay live)",
348
- choices: [
349
- { name: "1. Yes", value: "yes" },
350
- { name: "2. No", value: "no" },
351
- ],
352
- });
353
-
354
- if (action === "no") {
317
+ info("Remove these from jack's tracking?");
318
+ const choice = await promptSelect(["Yes", "No"]);
319
+
320
+ if (choice !== 0) {
355
321
  info("Cleanup cancelled");
356
322
  return;
357
323
  }
@@ -361,7 +327,167 @@ async function cleanupProjects(): Promise<void> {
361
327
 
362
328
  console.error("");
363
329
  success(`Removed ${scan.stale.length} entry/entries from jack's registry`);
364
- if (deployedStale.length > 0) {
365
- info("Note: Deployed services are still live");
330
+ }
331
+
332
+ /**
333
+ * Remove a project from registry and jack cloud
334
+ */
335
+ async function removeProjectEntry(args: string[]): Promise<void> {
336
+ const name = args.find((arg) => !arg.startsWith("--"));
337
+ const yes = args.includes("--yes");
338
+
339
+ if (!name) {
340
+ error("Project name required");
341
+ info("Usage: jack projects remove <name>");
342
+ process.exit(1);
343
+ }
344
+
345
+ // Use resolver to find project anywhere (registry OR control plane)
346
+ outputSpinner.start("Checking project status...");
347
+ const project = await resolveProject(name);
348
+ outputSpinner.stop();
349
+
350
+ if (!project) {
351
+ error(`Project "${name}" not found`);
352
+ info("Check available projects with: jack projects");
353
+ process.exit(1);
354
+ }
355
+
356
+ // Show what we found and where
357
+ console.error("");
358
+ info(`Removing "${name}"...`);
359
+ console.error("");
360
+
361
+ // Show project details
362
+ if (project.localPath) {
363
+ item(`Workspace: ${project.localPath}`);
364
+ }
365
+ if (project.url) {
366
+ item(`URL: ${project.url}`);
367
+ }
368
+
369
+ // Show where we'll remove from
370
+ const locations: string[] = [];
371
+ if (project.sources.registry) {
372
+ locations.push("local registry");
373
+ }
374
+ if (project.sources.controlPlane) {
375
+ locations.push("jack cloud");
376
+ }
377
+ if (locations.length > 0) {
378
+ item(`Will remove from: ${locations.join(", ")}`);
379
+ }
380
+
381
+ // Warn if still deployed
382
+ if (project.status === "live") {
383
+ console.error("");
384
+ warn("Project is still deployed; removal does not undeploy the worker");
385
+ }
386
+
387
+ console.error("");
388
+
389
+ if (!yes) {
390
+ info("Remove this project?");
391
+ const choice = await promptSelect(["Yes", "No"]);
392
+
393
+ if (choice !== 0) {
394
+ info("Removal cancelled");
395
+ return;
396
+ }
397
+ }
398
+
399
+ // Use resolver's removeProject to clean up everywhere
400
+ outputSpinner.start("Removing project...");
401
+ const result = await removeProjectEverywhere(name);
402
+ outputSpinner.stop();
403
+
404
+ console.error("");
405
+
406
+ // Show what was removed
407
+ if (result.removed.length > 0) {
408
+ for (const location of result.removed) {
409
+ success(`Removed from ${location}`);
410
+ }
411
+ }
412
+
413
+ // Show any errors
414
+ if (result.errors.length > 0) {
415
+ for (const err of result.errors) {
416
+ warn(err);
417
+ }
418
+ }
419
+
420
+ // Final status
421
+ if (result.removed.length > 0 && result.errors.length === 0) {
422
+ console.error("");
423
+ success(`Project "${name}" removed`);
424
+ } else if (result.removed.length === 0) {
425
+ console.error("");
426
+ error(`Failed to remove "${name}"`);
366
427
  }
428
+
429
+ // Hint about undeploying
430
+ if (project.status === "live") {
431
+ console.error("");
432
+ info(`To undeploy the worker, run: jack down ${name}`);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Scan a directory for jack projects and register them
438
+ */
439
+ async function scanProjects(args: string[]): Promise<void> {
440
+ const targetDir = args[0] || process.cwd();
441
+ const absoluteDir = resolve(targetDir);
442
+
443
+ if (!existsSync(absoluteDir)) {
444
+ error(`Directory not found: ${targetDir}`);
445
+ process.exit(1);
446
+ }
447
+
448
+ outputSpinner.start(`Scanning ${targetDir} for jack projects...`);
449
+
450
+ const { scanDirectoryForProjects, registerDiscoveredProjects } = await import(
451
+ "../lib/local-paths.ts"
452
+ );
453
+
454
+ const discovered = await scanDirectoryForProjects(absoluteDir);
455
+ outputSpinner.stop();
456
+
457
+ if (discovered.length === 0) {
458
+ info("No jack projects found");
459
+ info("Projects must have a wrangler.toml or wrangler.jsonc file");
460
+ return;
461
+ }
462
+
463
+ console.error("");
464
+ info(`Found ${discovered.length} project(s):`);
465
+
466
+ const home = homedir();
467
+ for (let i = 0; i < discovered.length; i++) {
468
+ const proj = discovered[i];
469
+ if (!proj) continue;
470
+ const displayPath = proj.path.startsWith(home) ? `~${proj.path.slice(home.length)}` : proj.path;
471
+ const isLast = i === discovered.length - 1;
472
+ const prefix = isLast ? "└──" : "├──";
473
+ console.error(
474
+ ` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${colors.dim}${displayPath}${colors.reset}`,
475
+ );
476
+ }
477
+
478
+ // Register all discovered projects
479
+ await registerDiscoveredProjects(discovered);
480
+
481
+ console.error("");
482
+ success(`Registered ${discovered.length} local path(s)`);
483
+ }
484
+
485
+ /**
486
+ * Handle down subcommand - routes to top-level down command
487
+ */
488
+ async function handleDown(args: string[]): Promise<void> {
489
+ const { default: down } = await import("./down.ts");
490
+ const projectName = args.find((arg) => !arg.startsWith("--"));
491
+ const force = args.includes("--force");
492
+ return await down(projectName, { force });
367
493
  }