@getjack/jack 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/logs.ts +8 -18
  8. package/src/commands/new.ts +7 -1
  9. package/src/commands/projects.ts +162 -105
  10. package/src/commands/secrets.ts +7 -6
  11. package/src/commands/services.ts +5 -4
  12. package/src/commands/tag.ts +282 -0
  13. package/src/commands/unlink.ts +30 -0
  14. package/src/index.ts +46 -1
  15. package/src/lib/auth/index.ts +2 -0
  16. package/src/lib/auth/store.ts +26 -2
  17. package/src/lib/binding-validator.ts +4 -13
  18. package/src/lib/build-helper.ts +93 -5
  19. package/src/lib/control-plane.ts +48 -0
  20. package/src/lib/deploy-mode.ts +1 -1
  21. package/src/lib/managed-deploy.ts +11 -1
  22. package/src/lib/managed-down.ts +7 -20
  23. package/src/lib/paths-index.test.ts +546 -0
  24. package/src/lib/paths-index.ts +310 -0
  25. package/src/lib/project-link.test.ts +459 -0
  26. package/src/lib/project-link.ts +279 -0
  27. package/src/lib/project-list.test.ts +581 -0
  28. package/src/lib/project-list.ts +445 -0
  29. package/src/lib/project-operations.ts +304 -183
  30. package/src/lib/project-resolver.ts +191 -211
  31. package/src/lib/tags.ts +389 -0
  32. package/src/lib/telemetry.ts +81 -168
  33. package/src/lib/zip-packager.ts +9 -0
  34. package/src/templates/index.ts +5 -3
  35. package/templates/api/.jack/template.json +4 -0
  36. package/templates/hello/.jack/template.json +4 -0
  37. package/templates/miniapp/.jack/template.json +4 -0
  38. package/templates/nextjs/.jack.json +28 -0
  39. package/templates/nextjs/app/globals.css +9 -0
  40. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -4,29 +4,30 @@
4
4
  * Users see "projects". We handle the plumbing.
5
5
  *
6
6
  * Resolution strategy:
7
- * 1. Check local registry (fast cache)
8
- * 2. Check control plane (if logged in)
9
- * 3. Update registry cache with remote data
7
+ * 1. Check .jack/project.json (local link)
8
+ * 2. If managed: fetch from control plane (authoritative)
9
+ * 3. If BYO: use local link info only
10
10
  *
11
11
  * Control plane is authoritative for managed projects.
12
- * Registry is a cache that can be rebuilt.
12
+ * .jack/project.json is the local link (not a cache).
13
13
  */
14
14
 
15
- import { isLoggedIn } from "./auth/index.ts";
15
+ import { type AuthState, getAuthState, isLoggedIn } from "./auth/index.ts";
16
16
  import {
17
17
  type ManagedProject,
18
18
  fetchProjectResources,
19
19
  findProjectBySlug,
20
20
  listManagedProjects,
21
21
  } from "./control-plane.ts";
22
- import { getAllLocalPaths } from "./local-paths.ts";
22
+ import { getAllPaths, unregisterPath } from "./paths-index.ts";
23
23
  import {
24
- type Project as RegistryProject,
25
- getAllProjects,
26
- getProject,
27
- registerProject,
28
- removeProject as removeFromRegistry,
29
- } from "./registry.ts";
24
+ type DeployMode,
25
+ type LocalProjectLink,
26
+ getDeployMode,
27
+ getProjectId,
28
+ readProjectLink,
29
+ unlinkProject,
30
+ } from "./project-link.ts";
30
31
  import {
31
32
  type ResolvedResources,
32
33
  convertControlPlaneResources,
@@ -36,7 +37,7 @@ import {
36
37
  /**
37
38
  * User-facing project status
38
39
  */
39
- export type ProjectStatus = "live" | "local-only" | "error" | "syncing";
40
+ export type ProjectStatus = "live" | "local-only" | "error" | "syncing" | "auth-expired";
40
41
 
41
42
  /**
42
43
  * Unified project representation
@@ -52,7 +53,6 @@ export interface ResolvedProject {
52
53
 
53
54
  // Where we found it (internal, not shown to user)
54
55
  sources: {
55
- registry: boolean;
56
56
  controlPlane: boolean;
57
57
  filesystem: boolean;
58
58
  };
@@ -64,48 +64,44 @@ export interface ResolvedProject {
64
64
  orgId: string;
65
65
  };
66
66
 
67
+ // Deploy mode
68
+ deployMode?: DeployMode;
69
+
67
70
  // Metadata
68
71
  createdAt: string;
69
72
  updatedAt?: string;
70
73
 
71
74
  // Resources (fetched on-demand)
72
75
  resources?: ResolvedResources;
76
+
77
+ // Tags (from local project link)
78
+ tags?: string[];
73
79
  }
74
80
 
75
81
  /**
76
- * Convert registry project to resolved project
82
+ * Convert a local project link to a resolved project
77
83
  */
78
- function fromRegistryProject(name: string, project: RegistryProject): ResolvedProject {
79
- // Determine status from registry data
80
- let status: ProjectStatus = "local-only";
81
- if (project.status === "live") {
82
- status = "live";
83
- } else if (project.status === "build_failed") {
84
- status = "error";
85
- } else if (project.lastDeployed || project.remote) {
86
- // If we have a deployment or remote metadata, assume it's live
87
- status = "live";
88
- }
84
+ function fromLocalLink(link: LocalProjectLink, localPath: string): ResolvedProject {
85
+ const isByo = link.deploy_mode === "byo";
89
86
 
90
87
  return {
91
- name,
92
- slug: project.remote?.project_slug || name,
93
- status,
94
- url: project.workerUrl || project.remote?.runjack_url || undefined,
88
+ name: localPath.split("/").pop() || "unknown",
89
+ slug: localPath.split("/").pop() || "unknown",
90
+ status: isByo ? "local-only" : "syncing", // BYO is local-only, managed needs control plane check
95
91
  sources: {
96
- registry: true,
97
92
  controlPlane: false,
98
- filesystem: false, // localPath removed from registry - filesystem detection done elsewhere
93
+ filesystem: true,
99
94
  },
100
- localPath: undefined, // localPath removed from registry
101
- remote: project.remote
102
- ? {
103
- projectId: project.remote.project_id,
104
- orgId: project.remote.org_id,
105
- }
106
- : undefined,
107
- createdAt: project.createdAt,
108
- updatedAt: project.lastDeployed || undefined,
95
+ localPath,
96
+ remote: isByo
97
+ ? undefined
98
+ : {
99
+ projectId: link.project_id,
100
+ orgId: "", // Will be filled from control plane
101
+ },
102
+ deployMode: link.deploy_mode,
103
+ createdAt: link.linked_at,
104
+ tags: link.tags,
109
105
  };
110
106
  }
111
107
 
@@ -115,6 +111,16 @@ function fromRegistryProject(name: string, project: RegistryProject): ResolvedPr
115
111
  function fromManagedProject(managed: ManagedProject): ResolvedProject {
116
112
  const status: ProjectStatus = managed.status === "active" ? "live" : "error";
117
113
 
114
+ // Parse tags from JSON string (e.g., '["backend", "api"]')
115
+ let tags: string[] | undefined;
116
+ if (managed.tags) {
117
+ try {
118
+ tags = JSON.parse(managed.tags);
119
+ } catch {
120
+ // Invalid JSON, ignore
121
+ }
122
+ }
123
+
118
124
  return {
119
125
  name: managed.name,
120
126
  slug: managed.slug,
@@ -122,7 +128,6 @@ function fromManagedProject(managed: ManagedProject): ResolvedProject {
122
128
  url: `https://${managed.slug}.runjack.xyz`,
123
129
  errorMessage: managed.status === "error" ? "deployment failed" : undefined,
124
130
  sources: {
125
- registry: false,
126
131
  controlPlane: true,
127
132
  filesystem: false,
128
133
  },
@@ -130,27 +135,33 @@ function fromManagedProject(managed: ManagedProject): ResolvedProject {
130
135
  projectId: managed.id,
131
136
  orgId: managed.org_id,
132
137
  },
138
+ deployMode: "managed",
133
139
  createdAt: managed.created_at,
134
140
  updatedAt: managed.updated_at,
141
+ tags,
135
142
  };
136
143
  }
137
144
 
138
145
  /**
139
- * Merge registry and managed project data
146
+ * Merge local and managed project data
140
147
  */
141
- function mergeProjects(registry: ResolvedProject, managed: ResolvedProject): ResolvedProject {
148
+ function mergeProjects(local: ResolvedProject, managed: ResolvedProject): ResolvedProject {
142
149
  return {
143
- ...registry,
150
+ ...local,
151
+ name: managed.name, // Control plane name is authoritative
152
+ slug: managed.slug,
144
153
  status: managed.status, // Control plane is authoritative for status
145
- url: managed.url || registry.url,
154
+ url: managed.url || local.url,
146
155
  errorMessage: managed.errorMessage,
147
156
  sources: {
148
- registry: true,
149
157
  controlPlane: true,
150
- filesystem: registry.sources.filesystem,
158
+ filesystem: local.sources.filesystem,
151
159
  },
152
160
  remote: managed.remote,
153
- updatedAt: managed.updatedAt || registry.updatedAt,
161
+ deployMode: "managed",
162
+ updatedAt: managed.updatedAt || local.updatedAt,
163
+ // Local tags take priority; fall back to remote if local has none
164
+ tags: local.tags?.length ? local.tags : managed.tags,
154
165
  };
155
166
  }
156
167
 
@@ -166,29 +177,6 @@ export interface ResolveProjectOptions {
166
177
  matchByName?: boolean;
167
178
  }
168
179
 
169
- interface RegistryIndex {
170
- byRemoteId: Map<string, string>;
171
- byRemoteSlug: Map<string, string>;
172
- }
173
-
174
- function buildRegistryIndexes(registryProjects: Record<string, RegistryProject>): RegistryIndex {
175
- const byRemoteId = new Map<string, string>();
176
- const byRemoteSlug = new Map<string, string>();
177
-
178
- for (const [name, project] of Object.entries(registryProjects)) {
179
- const remote = project.remote;
180
- if (!remote) continue;
181
- if (remote.project_id) {
182
- byRemoteId.set(remote.project_id, name);
183
- }
184
- if (remote.project_slug) {
185
- byRemoteSlug.set(remote.project_slug, name);
186
- }
187
- }
188
-
189
- return { byRemoteId, byRemoteSlug };
190
- }
191
-
192
180
  /**
193
181
  * Resolve project resources based on deploy mode.
194
182
  * For managed: fetch from control plane
@@ -222,9 +210,9 @@ export async function resolveProjectResources(
222
210
  }
223
211
 
224
212
  /**
225
- * Resolve a project by name/slug
226
- * Checks: registry control plane → filesystem
227
- * Caches result in registry
213
+ * Resolve a project by name/slug or from current directory
214
+ * Checks: .jack/project.json -> control plane
215
+ * No caching - fresh reads only
228
216
  */
229
217
  export async function resolveProject(
230
218
  name: string,
@@ -232,46 +220,48 @@ export async function resolveProject(
232
220
  ): Promise<ResolvedProject | null> {
233
221
  let resolved: ResolvedProject | null = null;
234
222
  const matchByName = options?.matchByName !== false;
235
- let registryName = name;
236
- let registryProjects: Record<string, RegistryProject> | null = null;
237
- let registryIndex: RegistryIndex | null = null;
238
-
239
- // Check registry first (fast)
240
- let registryProject = await getProject(name);
241
- if (!registryProject) {
242
- registryProjects = await getAllProjects();
243
- registryIndex = buildRegistryIndexes(registryProjects);
244
- const slugMatchName = registryIndex.byRemoteSlug.get(name);
245
- if (slugMatchName) {
246
- registryProject = registryProjects[slugMatchName] ?? null;
247
- registryName = slugMatchName;
248
- }
249
- }
250
- if (registryProject) {
251
- resolved = fromRegistryProject(registryName, registryProject);
223
+ const projectPath = options?.projectPath || process.cwd();
224
+
225
+ // First, check if we're resolving from a local path with .jack/project.json
226
+ const link = await readProjectLink(projectPath);
227
+
228
+ if (link) {
229
+ // We have a local link - start with that
230
+ resolved = fromLocalLink(link, projectPath);
252
231
 
253
- // If it's a BYOC project, don't check control plane
254
- if (registryProject.deploy_mode === "byo") {
232
+ if (link.deploy_mode === "byo") {
233
+ // BYO project - use local link info only, no control plane
255
234
  // resolved stays as-is
256
- } else if (registryProject.deploy_mode === "managed" && (await isLoggedIn())) {
257
- // If it's managed, try to get latest status from control plane
258
- try {
259
- const managed = await findProjectBySlug(resolved.slug);
260
- if (managed) {
261
- resolved = mergeProjects(resolved, fromManagedProject(managed));
262
-
263
- // Update registry cache with latest status
264
- await registerProject(registryName, {
265
- ...registryProject,
266
- status: managed.status === "active" ? "live" : "build_failed",
267
- });
235
+ } else if (link.deploy_mode === "managed") {
236
+ // Managed project - check auth state and fetch from control plane
237
+ const authState = await getAuthState();
238
+
239
+ if (authState === "logged-in") {
240
+ try {
241
+ // Try to find by project ID first via listing
242
+ const managedProjects = await listManagedProjects();
243
+ const managed = managedProjects.find((p) => p.id === link.project_id);
244
+
245
+ if (managed) {
246
+ resolved = mergeProjects(resolved, fromManagedProject(managed));
247
+ } else {
248
+ // Project ID not found in control plane - might be deleted
249
+ resolved.status = "error";
250
+ resolved.errorMessage = "Project not found in jack cloud";
251
+ }
252
+ } catch {
253
+ // Control plane unavailable, use local data with syncing status
254
+ resolved.status = "syncing";
268
255
  }
269
- } catch {
270
- // Control plane unavailable, use cached data
256
+ } else if (authState === "session-expired") {
257
+ // Session expired - show clear status
258
+ resolved.status = "auth-expired";
259
+ resolved.errorMessage = "Session expired. Run: jack login";
271
260
  }
261
+ // If not-logged-in, keep "syncing" status from fromLocalLink
272
262
  }
273
263
  } else if (await isLoggedIn()) {
274
- // Not in registry, check control plane if logged in
264
+ // No local link - check control plane by slug/name if logged in
275
265
  try {
276
266
  let managed = await findProjectBySlug(name);
277
267
  if (!managed && matchByName) {
@@ -279,43 +269,14 @@ export async function resolveProject(
279
269
  managed = managedProjects.find((project) => project.name === name) ?? null;
280
270
  }
281
271
  if (managed) {
282
- if (!registryProjects || !registryIndex) {
283
- registryProjects = await getAllProjects();
284
- registryIndex = buildRegistryIndexes(registryProjects);
285
- }
286
-
287
- const existingRegistryName =
288
- registryIndex.byRemoteId.get(managed.id) ?? registryIndex.byRemoteSlug.get(managed.slug);
289
-
290
- if (existingRegistryName && registryProjects[existingRegistryName]) {
291
- const existingProject = registryProjects[existingRegistryName];
292
- resolved = mergeProjects(
293
- fromRegistryProject(existingRegistryName, existingProject),
294
- fromManagedProject(managed),
295
- );
296
-
297
- // Update registry cache with latest status
298
- await registerProject(existingRegistryName, {
299
- ...existingProject,
300
- status: managed.status === "active" ? "live" : "build_failed",
301
- });
302
- } else {
303
- resolved = fromManagedProject(managed);
304
-
305
- // Cache in registry for future lookups
306
- await registerProject(managed.slug, {
307
- workerUrl: resolved.url || null,
308
- createdAt: managed.created_at,
309
- lastDeployed: managed.updated_at,
310
- status: managed.status === "active" ? "live" : "build_failed",
311
- deploy_mode: "managed",
312
- remote: {
313
- project_id: managed.id,
314
- project_slug: managed.slug,
315
- org_id: managed.org_id,
316
- runjack_url: `https://${managed.slug}.runjack.xyz`,
317
- },
318
- });
272
+ resolved = fromManagedProject(managed);
273
+
274
+ // Check if we have a local path for this project
275
+ const allPaths = await getAllPaths();
276
+ const localPaths = allPaths[managed.id];
277
+ if (localPaths && localPaths.length > 0) {
278
+ resolved.localPath = localPaths[0];
279
+ resolved.sources.filesystem = true;
319
280
  }
320
281
  }
321
282
  } catch {
@@ -323,9 +284,28 @@ export async function resolveProject(
323
284
  }
324
285
  }
325
286
 
287
+ // If still not found, check paths index for local projects
288
+ if (!resolved) {
289
+ const allPaths = await getAllPaths();
290
+ for (const [projectId, paths] of Object.entries(allPaths)) {
291
+ for (const localPath of paths) {
292
+ const localLink = await readProjectLink(localPath);
293
+ if (localLink) {
294
+ const dirName = localPath.split("/").pop() || "";
295
+ // Match by directory name or project_id
296
+ if (dirName === name || projectId === name) {
297
+ resolved = fromLocalLink(localLink, localPath);
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ if (resolved) break;
303
+ }
304
+ }
305
+
326
306
  // Optionally fetch resources
327
307
  if (resolved && options?.includeResources) {
328
- const resources = await resolveProjectResources(resolved, options.projectPath || process.cwd());
308
+ const resources = await resolveProjectResources(resolved, resolved.localPath || projectPath);
329
309
  if (resources) {
330
310
  resolved.resources = resources;
331
311
  }
@@ -336,21 +316,30 @@ export async function resolveProject(
336
316
 
337
317
  /**
338
318
  * List ALL projects from all sources
339
- * Merges and dedupes automatically
319
+ * Merges and dedupes by project_id
340
320
  */
341
321
  export async function listAllProjects(): Promise<ResolvedProject[]> {
342
322
  const projectMap = new Map<string, ResolvedProject>();
343
323
 
344
- // Get all registry projects
345
- const registryProjects = await getAllProjects();
346
- const registryIndex = buildRegistryIndexes(registryProjects);
347
- for (const [name, project] of Object.entries(registryProjects)) {
348
- const key = project.remote?.project_slug ?? name;
349
- projectMap.set(key, fromRegistryProject(name, project));
324
+ // Get all local projects from paths index
325
+ const allPaths = await getAllPaths();
326
+
327
+ for (const [projectId, paths] of Object.entries(allPaths)) {
328
+ // Read the first valid path's link
329
+ for (const localPath of paths) {
330
+ const link = await readProjectLink(localPath);
331
+ if (link) {
332
+ const resolved = fromLocalLink(link, localPath);
333
+ projectMap.set(projectId, resolved);
334
+ break; // Use first valid path
335
+ }
336
+ }
350
337
  }
351
338
 
352
- // Get all managed projects if logged in
353
- if (await isLoggedIn()) {
339
+ // Get all managed projects based on auth state
340
+ const authState = await getAuthState();
341
+
342
+ if (authState === "logged-in") {
354
343
  try {
355
344
  const managedProjects = await listManagedProjects();
356
345
 
@@ -358,61 +347,35 @@ export async function listAllProjects(): Promise<ResolvedProject[]> {
358
347
  const activeProjects = managedProjects.filter((p) => p.status !== "deleted");
359
348
 
360
349
  for (const managed of activeProjects) {
361
- const existing = projectMap.get(managed.slug);
350
+ const existing = projectMap.get(managed.id);
362
351
 
363
352
  if (existing) {
364
- // Merge with registry data
365
- projectMap.set(managed.slug, mergeProjects(existing, fromManagedProject(managed)));
353
+ // Merge with local data - control plane is authoritative
354
+ projectMap.set(managed.id, mergeProjects(existing, fromManagedProject(managed)));
366
355
  } else {
367
- // New project not in registry
356
+ // New project not in local index
368
357
  const resolved = fromManagedProject(managed);
369
- projectMap.set(managed.slug, resolved);
370
-
371
- // Cache in registry
372
- const registryName =
373
- registryIndex.byRemoteId.get(managed.id) ??
374
- registryIndex.byRemoteSlug.get(managed.slug) ??
375
- managed.slug;
376
- await registerProject(registryName, {
377
- workerUrl: resolved.url || null,
378
- createdAt: managed.created_at,
379
- lastDeployed: managed.updated_at,
380
- status: managed.status === "active" ? "live" : "build_failed",
381
- deploy_mode: "managed",
382
- remote: {
383
- project_id: managed.id,
384
- project_slug: managed.slug,
385
- org_id: managed.org_id,
386
- runjack_url: `https://${managed.slug}.runjack.xyz`,
387
- },
388
- });
358
+
359
+ // Check if we have a local path for this project
360
+ const localPaths = allPaths[managed.id];
361
+ if (localPaths && localPaths.length > 0) {
362
+ resolved.localPath = localPaths[0];
363
+ resolved.sources.filesystem = true;
364
+ }
365
+
366
+ projectMap.set(managed.id, resolved);
389
367
  }
390
368
  }
391
369
  } catch {
392
- // Control plane unavailable, use registry-only data
370
+ // Control plane unavailable, use local-only data
393
371
  }
394
- }
395
-
396
- // Enrich with local paths
397
- const localPaths = await getAllLocalPaths();
398
-
399
- for (const [name, paths] of Object.entries(localPaths)) {
400
- const existing = projectMap.get(name);
401
-
402
- if (existing) {
403
- // Update existing project with local info
404
- existing.localPath = paths[0]; // Primary path
405
- existing.sources.filesystem = true;
406
- } else {
407
- // Project exists locally but not in registry/cloud
408
- projectMap.set(name, {
409
- name,
410
- slug: name,
411
- status: "local-only",
412
- sources: { registry: false, controlPlane: false, filesystem: true },
413
- localPath: paths[0],
414
- createdAt: new Date().toISOString(),
415
- });
372
+ } else if (authState === "session-expired") {
373
+ // Mark all managed projects as auth-expired
374
+ for (const [projectId, project] of projectMap) {
375
+ if (project.deployMode === "managed" && project.status === "syncing") {
376
+ project.status = "auth-expired";
377
+ project.errorMessage = "Session expired. Run: jack login";
378
+ }
416
379
  }
417
380
  }
418
381
 
@@ -436,21 +399,28 @@ export async function checkAvailability(name: string): Promise<{
436
399
 
437
400
  /**
438
401
  * Remove a project from everywhere
439
- * Handles: registry cleanup, control plane deletion, worker teardown
402
+ * Handles: .jack/ cleanup, paths index, control plane deletion
440
403
  */
441
- export async function removeProject(name: string): Promise<{
404
+ export async function removeProject(
405
+ name: string,
406
+ projectPath?: string,
407
+ ): Promise<{
442
408
  removed: string[];
443
409
  errors: string[];
444
410
  }> {
445
411
  const removed: string[] = [];
446
412
  const errors: string[] = [];
413
+ const localPath = projectPath || process.cwd();
447
414
 
448
415
  // Resolve project to find all locations
449
- const project = await resolveProject(name);
416
+ const project = await resolveProject(name, { projectPath: localPath });
450
417
  if (!project) {
451
418
  return { removed, errors: [`Project "${name}" not found`] };
452
419
  }
453
420
 
421
+ // Get project ID for paths index cleanup
422
+ const projectId = await getProjectId(localPath);
423
+
454
424
  // Remove from control plane if managed
455
425
  if (project.remote?.projectId) {
456
426
  try {
@@ -462,13 +432,23 @@ export async function removeProject(name: string): Promise<{
462
432
  }
463
433
  }
464
434
 
465
- // Remove from registry
466
- if (project.sources.registry) {
435
+ // Remove local .jack/ directory
436
+ if (project.sources.filesystem && project.localPath) {
437
+ try {
438
+ await unlinkProject(project.localPath);
439
+ removed.push("local link (.jack/)");
440
+ } catch (error) {
441
+ errors.push(`Failed to remove local link: ${error}`);
442
+ }
443
+ }
444
+
445
+ // Remove from paths index
446
+ if (projectId) {
467
447
  try {
468
- await removeFromRegistry(project.name);
469
- removed.push("local registry");
448
+ await unregisterPath(projectId, localPath);
449
+ removed.push("paths index");
470
450
  } catch (error) {
471
- errors.push(`Failed to remove from registry: ${error}`);
451
+ errors.push(`Failed to remove from paths index: ${error}`);
472
452
  }
473
453
  }
474
454