@getjack/jack 0.1.28 → 0.1.30

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 (125) hide show
  1. package/package.json +1 -1
  2. package/src/commands/cd.ts +163 -0
  3. package/src/commands/clone.ts +112 -68
  4. package/src/commands/domain.ts +506 -0
  5. package/src/commands/domains.ts +215 -0
  6. package/src/commands/down.ts +18 -12
  7. package/src/commands/hack.ts +185 -8
  8. package/src/commands/init.ts +52 -1
  9. package/src/commands/link.ts +25 -43
  10. package/src/commands/logs.ts +2 -2
  11. package/src/commands/mcp.ts +74 -3
  12. package/src/commands/new.ts +48 -54
  13. package/src/commands/projects.ts +53 -10
  14. package/src/commands/secrets.ts +5 -1
  15. package/src/commands/services.ts +16 -4
  16. package/src/commands/shell-init.ts +43 -0
  17. package/src/commands/ship.ts +2 -11
  18. package/src/commands/skills.ts +335 -0
  19. package/src/commands/update.ts +31 -0
  20. package/src/commands/upgrade.ts +14 -0
  21. package/src/index.ts +116 -24
  22. package/src/lib/agent-integration.ts +1 -2
  23. package/src/lib/agents.ts +2 -2
  24. package/src/lib/auth/login-flow.ts +1 -1
  25. package/src/lib/clone-core.ts +252 -0
  26. package/src/lib/config.ts +22 -0
  27. package/src/lib/control-plane.ts +31 -5
  28. package/src/lib/fuzzy.ts +93 -0
  29. package/src/lib/managed-deploy.ts +4 -1
  30. package/src/lib/managed-down.ts +20 -5
  31. package/src/lib/output.ts +90 -9
  32. package/src/lib/picker.ts +406 -0
  33. package/src/lib/project-detection.ts +5 -2
  34. package/src/lib/project-list.ts +66 -5
  35. package/src/lib/project-operations.ts +68 -6
  36. package/src/lib/prompts.ts +1 -1
  37. package/src/lib/services/db-execute.ts +8 -1
  38. package/src/lib/services/db-list.ts +4 -1
  39. package/src/lib/services/domain-operations.ts +379 -0
  40. package/src/lib/services/storage-config.ts +1 -5
  41. package/src/lib/services/storage-delete.ts +1 -1
  42. package/src/lib/services/storage-info.ts +2 -4
  43. package/src/lib/services/vectorize-config.ts +1 -5
  44. package/src/lib/services/vectorize-create.ts +3 -1
  45. package/src/lib/shell-integration.ts +202 -0
  46. package/src/lib/telemetry-config.ts +50 -4
  47. package/src/lib/telemetry.ts +71 -2
  48. package/src/lib/version-check.ts +1 -3
  49. package/src/lib/wrangler-config.test.ts +2 -2
  50. package/src/lib/wrangler-config.ts +1 -1
  51. package/src/lib/zip-packager.ts +1 -3
  52. package/src/mcp/tools/index.ts +261 -7
  53. package/src/templates/index.ts +10 -1
  54. package/templates/ai-chat/.jack.json +1 -5
  55. package/templates/ai-chat/public/chat.js +130 -130
  56. package/templates/ai-chat/src/index.ts +9 -13
  57. package/templates/ai-chat/src/jack-ai.ts +6 -2
  58. package/templates/saas/.jack.json +6 -1
  59. package/templates/saas/src/auth.ts +8 -4
  60. package/templates/saas/src/client/App.tsx +22 -7
  61. package/templates/saas/src/client/components/ProtectedRoute.tsx +9 -2
  62. package/templates/saas/src/client/components/ThemeToggle.tsx +1 -6
  63. package/templates/saas/src/client/components/ui/accordion.tsx +1 -1
  64. package/templates/saas/src/client/components/ui/alert-dialog.tsx +2 -2
  65. package/templates/saas/src/client/components/ui/alert.tsx +2 -2
  66. package/templates/saas/src/client/components/ui/avatar.tsx +1 -1
  67. package/templates/saas/src/client/components/ui/badge.tsx +2 -2
  68. package/templates/saas/src/client/components/ui/breadcrumb.tsx +1 -1
  69. package/templates/saas/src/client/components/ui/button-group.tsx +2 -2
  70. package/templates/saas/src/client/components/ui/button.tsx +2 -2
  71. package/templates/saas/src/client/components/ui/card.tsx +1 -1
  72. package/templates/saas/src/client/components/ui/carousel.tsx +2 -2
  73. package/templates/saas/src/client/components/ui/checkbox.tsx +1 -1
  74. package/templates/saas/src/client/components/ui/command.tsx +2 -2
  75. package/templates/saas/src/client/components/ui/context-menu.tsx +1 -1
  76. package/templates/saas/src/client/components/ui/dialog.tsx +1 -1
  77. package/templates/saas/src/client/components/ui/drawer.tsx +1 -1
  78. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +1 -1
  79. package/templates/saas/src/client/components/ui/empty.tsx +1 -1
  80. package/templates/saas/src/client/components/ui/field.tsx +2 -2
  81. package/templates/saas/src/client/components/ui/form.tsx +5 -5
  82. package/templates/saas/src/client/components/ui/hover-card.tsx +1 -1
  83. package/templates/saas/src/client/components/ui/input-group.tsx +3 -3
  84. package/templates/saas/src/client/components/ui/input-otp.tsx +1 -1
  85. package/templates/saas/src/client/components/ui/input.tsx +1 -1
  86. package/templates/saas/src/client/components/ui/item.tsx +3 -3
  87. package/templates/saas/src/client/components/ui/label.tsx +1 -1
  88. package/templates/saas/src/client/components/ui/menubar.tsx +1 -1
  89. package/templates/saas/src/client/components/ui/navigation-menu.tsx +1 -1
  90. package/templates/saas/src/client/components/ui/pagination.tsx +2 -2
  91. package/templates/saas/src/client/components/ui/popover.tsx +1 -1
  92. package/templates/saas/src/client/components/ui/progress.tsx +1 -1
  93. package/templates/saas/src/client/components/ui/radio-group.tsx +1 -1
  94. package/templates/saas/src/client/components/ui/resizable.tsx +1 -1
  95. package/templates/saas/src/client/components/ui/scroll-area.tsx +1 -1
  96. package/templates/saas/src/client/components/ui/select.tsx +1 -1
  97. package/templates/saas/src/client/components/ui/separator.tsx +1 -1
  98. package/templates/saas/src/client/components/ui/sheet.tsx +1 -1
  99. package/templates/saas/src/client/components/ui/sidebar.tsx +4 -4
  100. package/templates/saas/src/client/components/ui/slider.tsx +1 -1
  101. package/templates/saas/src/client/components/ui/switch.tsx +1 -1
  102. package/templates/saas/src/client/components/ui/table.tsx +1 -1
  103. package/templates/saas/src/client/components/ui/tabs.tsx +1 -1
  104. package/templates/saas/src/client/components/ui/textarea.tsx +1 -1
  105. package/templates/saas/src/client/components/ui/toggle-group.tsx +3 -3
  106. package/templates/saas/src/client/components/ui/toggle.tsx +2 -2
  107. package/templates/saas/src/client/components/ui/tooltip.tsx +1 -1
  108. package/templates/saas/src/client/hooks/useSubscription.ts +5 -4
  109. package/templates/saas/src/client/lib/auth-client.ts +1 -1
  110. package/templates/saas/src/client/lib/plans.ts +1 -6
  111. package/templates/saas/src/client/lib/utils.ts +1 -1
  112. package/templates/saas/src/client/main.tsx +1 -1
  113. package/templates/saas/src/client/pages/DashboardPage.tsx +41 -9
  114. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +11 -2
  115. package/templates/saas/src/client/pages/HomePage.tsx +11 -2
  116. package/templates/saas/src/client/pages/LoginPage.tsx +11 -2
  117. package/templates/saas/src/client/pages/PricingPage.tsx +20 -10
  118. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +14 -11
  119. package/templates/saas/src/client/pages/SignupPage.tsx +11 -2
  120. package/templates/saas/src/index.ts +28 -19
  121. package/templates/saas/vite.config.ts +1 -1
  122. package/templates/semantic-search/.jack.json +1 -5
  123. package/templates/semantic-search/src/index.ts +8 -4
  124. package/templates/semantic-search/src/jack-ai.ts +6 -2
  125. package/templates/semantic-search/src/jack-vectorize.ts +5 -1
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Clone Core - reusable clone logic for CLI and other callers
3
+ *
4
+ * This module extracts the core clone functionality from the clone command
5
+ * so it can be reused by other commands (e.g., `jack cd` for auto-cloning).
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { downloadProjectSource, fetchProjectTags } from "./control-plane.ts";
11
+ import { formatSize } from "./format.ts";
12
+ import { registerPath } from "./paths-index.ts";
13
+ import { linkProject, updateProjectLink } from "./project-link.ts";
14
+ import { resolveProject } from "./project-resolver.ts";
15
+ import { cloneFromCloud, getRemoteManifest } from "./storage/index.ts";
16
+ import { extractZipToDirectory } from "./zip-utils.ts";
17
+
18
+ /**
19
+ * Options for the clone operation
20
+ */
21
+ export interface CloneOptions {
22
+ /**
23
+ * When true, don't show spinners or progress output.
24
+ * Useful for programmatic calls or when called from other commands.
25
+ */
26
+ silent?: boolean;
27
+
28
+ /**
29
+ * When true, skip interactive prompts for collision handling.
30
+ * If target directory exists, throws an error instead of prompting.
31
+ */
32
+ skipPrompts?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Result of a clone operation
37
+ */
38
+ export interface CloneResult {
39
+ /** The final absolute path where the project was cloned */
40
+ path: string;
41
+ /** Number of files restored */
42
+ fileCount: number;
43
+ /** Whether this was a managed (Jack Cloud) or BYO project */
44
+ mode: "managed" | "byo";
45
+ /** Project ID if managed */
46
+ projectId?: string;
47
+ /** Number of tags restored (if any) */
48
+ tagsRestored?: number;
49
+ }
50
+
51
+ /**
52
+ * Error thrown when clone cannot proceed due to existing directory
53
+ */
54
+ export class CloneCollisionError extends Error {
55
+ constructor(
56
+ public readonly targetDir: string,
57
+ public readonly displayName: string,
58
+ ) {
59
+ super(`Directory ${displayName} already exists`);
60
+ this.name = "CloneCollisionError";
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Error thrown when project is not found
66
+ */
67
+ export class ProjectNotFoundError extends Error {
68
+ constructor(
69
+ public readonly projectName: string,
70
+ public readonly isByo: boolean,
71
+ ) {
72
+ super(
73
+ isByo
74
+ ? `Project not found: ${projectName}. For BYO projects, run 'jack sync' first to backup your project.`
75
+ : `Project not found: ${projectName}`,
76
+ );
77
+ this.name = "ProjectNotFoundError";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Reporter interface for clone progress (optional)
83
+ */
84
+ export interface CloneReporter {
85
+ onLookup?: (projectName: string) => void;
86
+ onLookupComplete?: (found: boolean, isManaged: boolean) => void;
87
+ onDownloadStart?: (source: "cloud" | "r2", details?: string) => void;
88
+ onDownloadComplete?: (fileCount: number, displayPath: string) => void;
89
+ onDownloadError?: (error: string) => void;
90
+ onTagsRestored?: (count: number) => void;
91
+ }
92
+
93
+ /**
94
+ * Clone a project from Jack Cloud or User R2 storage.
95
+ *
96
+ * This is the core clone logic that can be called from:
97
+ * - `jack clone` command (full UX with prompts)
98
+ * - `jack cd` command (silent, auto-clone mode)
99
+ * - MCP tools (programmatic access)
100
+ *
101
+ * @param projectName - The project name/slug to clone
102
+ * @param targetDir - The target directory (can be relative, will be resolved to absolute)
103
+ * @param options - Clone options (silent, skipPrompts)
104
+ * @param reporter - Optional reporter for progress callbacks
105
+ * @returns CloneResult with the final path and details
106
+ * @throws CloneCollisionError if target exists and skipPrompts is true
107
+ * @throws ProjectNotFoundError if project not found
108
+ * @throws Error for other failures (download, extract, etc.)
109
+ */
110
+ export async function cloneProject(
111
+ projectName: string,
112
+ targetDir: string,
113
+ options: CloneOptions = {},
114
+ reporter?: CloneReporter,
115
+ ): Promise<CloneResult> {
116
+ const { silent = false, skipPrompts = false } = options;
117
+ const absoluteTargetDir = resolve(targetDir);
118
+ const displayName = targetDir.startsWith("/") ? targetDir : targetDir.replace(/^\.\//, "");
119
+
120
+ // Check if target directory exists
121
+ if (existsSync(absoluteTargetDir)) {
122
+ if (skipPrompts) {
123
+ throw new CloneCollisionError(absoluteTargetDir, displayName);
124
+ }
125
+ // If not skipping prompts, caller is responsible for handling collision
126
+ // (the clone.ts command handles this with interactive prompts)
127
+ throw new CloneCollisionError(absoluteTargetDir, displayName);
128
+ }
129
+
130
+ // Look up project
131
+ reporter?.onLookup?.(projectName);
132
+
133
+ let project: Awaited<ReturnType<typeof resolveProject>> = null;
134
+ try {
135
+ project = await resolveProject(projectName);
136
+ } catch {
137
+ // Not found on control-plane, will fall back to User R2
138
+ }
139
+
140
+ reporter?.onLookupComplete?.(
141
+ !!project,
142
+ !!(project?.sources.controlPlane && project?.remote?.projectId),
143
+ );
144
+
145
+ // Managed mode: download from control-plane
146
+ if (project?.sources.controlPlane && project.remote?.projectId) {
147
+ reporter?.onDownloadStart?.("cloud");
148
+
149
+ try {
150
+ const sourceZip = await downloadProjectSource(projectName);
151
+ const fileCount = await extractZipToDirectory(sourceZip, absoluteTargetDir);
152
+
153
+ reporter?.onDownloadComplete?.(fileCount, `./${displayName}/`);
154
+
155
+ // Link to control-plane
156
+ await linkProject(absoluteTargetDir, project.remote.projectId, "managed");
157
+ await registerPath(project.remote.projectId, absoluteTargetDir);
158
+
159
+ // Fetch and restore tags from control plane
160
+ let tagsRestored = 0;
161
+ try {
162
+ const remoteTags = await fetchProjectTags(project.remote.projectId);
163
+ if (remoteTags.length > 0) {
164
+ await updateProjectLink(absoluteTargetDir, { tags: remoteTags });
165
+ tagsRestored = remoteTags.length;
166
+ reporter?.onTagsRestored?.(tagsRestored);
167
+ }
168
+ } catch {
169
+ // Silent fail - tag restoration is non-critical
170
+ }
171
+
172
+ return {
173
+ path: absoluteTargetDir,
174
+ fileCount,
175
+ mode: "managed",
176
+ projectId: project.remote.projectId,
177
+ tagsRestored: tagsRestored > 0 ? tagsRestored : undefined,
178
+ };
179
+ } catch (err) {
180
+ const message = err instanceof Error ? err.message : "Could not download project source";
181
+ reporter?.onDownloadError?.(message);
182
+ throw new Error(message);
183
+ }
184
+ }
185
+
186
+ // BYO mode: use existing User R2 flow
187
+ const manifest = await getRemoteManifest(projectName);
188
+
189
+ if (!manifest) {
190
+ throw new ProjectNotFoundError(projectName, true);
191
+ }
192
+
193
+ // Show file count and size in reporter
194
+ const totalSize = manifest.files.reduce((sum, f) => sum + f.size, 0);
195
+ reporter?.onDownloadStart?.("r2", `${manifest.files.length} file(s) (${formatSize(totalSize)})`);
196
+
197
+ // Download files
198
+ const result = await cloneFromCloud(projectName, absoluteTargetDir);
199
+
200
+ if (!result.success) {
201
+ const errorMsg = result.error || "Could not download project files";
202
+ reporter?.onDownloadError?.(errorMsg);
203
+ throw new Error(errorMsg);
204
+ }
205
+
206
+ reporter?.onDownloadComplete?.(result.filesDownloaded, `./${displayName}/`);
207
+
208
+ return {
209
+ path: absoluteTargetDir,
210
+ fileCount: result.filesDownloaded,
211
+ mode: "byo",
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Check if a project exists (either on Jack Cloud or User R2).
217
+ * Useful for pre-flight checks before cloning.
218
+ *
219
+ * @param projectName - The project name/slug to check
220
+ * @returns Object with exists flag and source info
221
+ */
222
+ export async function checkProjectExists(
223
+ projectName: string,
224
+ ): Promise<{ exists: boolean; isManaged: boolean; projectId?: string }> {
225
+ // Check Jack Cloud first
226
+ try {
227
+ const project = await resolveProject(projectName);
228
+ if (project?.sources.controlPlane && project.remote?.projectId) {
229
+ return {
230
+ exists: true,
231
+ isManaged: true,
232
+ projectId: project.remote.projectId,
233
+ };
234
+ }
235
+ } catch {
236
+ // Not found on control-plane
237
+ }
238
+
239
+ // Check User R2
240
+ const manifest = await getRemoteManifest(projectName);
241
+ if (manifest) {
242
+ return {
243
+ exists: true,
244
+ isManaged: false,
245
+ };
246
+ }
247
+
248
+ return {
249
+ exists: false,
250
+ isManaged: false,
251
+ };
252
+ }
package/src/lib/config.ts CHANGED
@@ -44,6 +44,28 @@ const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "jack");
44
44
  export const CONFIG_DIR = process.env.JACK_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
45
45
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
46
46
 
47
+ /**
48
+ * Default Jack home directory for projects
49
+ */
50
+ export const DEFAULT_JACK_HOME = join(homedir(), ".jack", "projects");
51
+
52
+ /**
53
+ * Get the Jack home directory for projects.
54
+ * Supports environment variable override via JACK_HOME.
55
+ * @returns The resolved Jack home path (~ expanded to home directory)
56
+ */
57
+ export function getJackHome(): string {
58
+ const envHome = process.env.JACK_HOME;
59
+ if (envHome) {
60
+ // Expand ~ to home directory if present
61
+ if (envHome.startsWith("~")) {
62
+ return join(homedir(), envHome.slice(1));
63
+ }
64
+ return envHome;
65
+ }
66
+ return DEFAULT_JACK_HOME;
67
+ }
68
+
47
69
  /**
48
70
  * Ensure config directory exists
49
71
  */
@@ -112,7 +112,11 @@ export async function createManagedProject(
112
112
  requestBody.use_prebuilt = options.usePrebuilt;
113
113
  }
114
114
 
115
- debug("Creating managed project", { name, template: options?.template, usePrebuilt: options?.usePrebuilt });
115
+ debug("Creating managed project", {
116
+ name,
117
+ template: options?.template,
118
+ usePrebuilt: options?.usePrebuilt,
119
+ });
116
120
  const start = Date.now();
117
121
 
118
122
  const response = await authFetch(`${getControlApiUrl()}/v1/projects`, {
@@ -234,9 +238,7 @@ export interface ManagedProject {
234
238
  export async function getManagedDatabaseInfo(projectId: string): Promise<DatabaseInfoResponse> {
235
239
  const { authFetch } = await import("./auth/index.ts");
236
240
 
237
- const response = await authFetch(
238
- `${getControlApiUrl()}/v1/projects/${projectId}/database/info`,
239
- );
241
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/database/info`);
240
242
 
241
243
  if (response.status === 404) {
242
244
  throw new Error("No database found for this project");
@@ -365,6 +367,28 @@ export async function listManagedProjects(): Promise<ManagedProject[]> {
365
367
  return data.projects;
366
368
  }
367
369
 
370
+ /**
371
+ * Find a managed project by ID.
372
+ */
373
+ export async function findProjectById(projectId: string): Promise<ManagedProject | null> {
374
+ const { authFetch } = await import("./auth/index.ts");
375
+
376
+ const response = await authFetch(
377
+ `${getControlApiUrl()}/v1/projects/${encodeURIComponent(projectId)}`,
378
+ );
379
+
380
+ if (response.status === 404) {
381
+ return null;
382
+ }
383
+
384
+ if (!response.ok) {
385
+ return null; // Silent fail - just can't look up the name
386
+ }
387
+
388
+ const data = (await response.json()) as { project: ManagedProject };
389
+ return data.project;
390
+ }
391
+
368
392
  /**
369
393
  * Find a managed project by slug.
370
394
  */
@@ -729,8 +753,10 @@ export async function publishProject(projectId: string): Promise<PublishProjectR
729
753
  export async function downloadProjectSource(slug: string): Promise<Buffer> {
730
754
  const { authFetch } = await import("./auth/index.ts");
731
755
 
756
+ // Use /me/projects endpoint to download your own project's source
757
+ // (works for both published and unpublished projects you own)
732
758
  const response = await authFetch(
733
- `${getControlApiUrl()}/v1/projects/by-slug/${encodeURIComponent(slug)}/source`,
759
+ `${getControlApiUrl()}/v1/me/projects/${encodeURIComponent(slug)}/source`,
734
760
  );
735
761
 
736
762
  if (!response.ok) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Simple fuzzy matching for project filtering
3
+ *
4
+ * Scoring priority:
5
+ * 1. Exact prefix match (highest)
6
+ * 2. Substring match (medium)
7
+ * 3. Character-by-character fuzzy (lower)
8
+ *
9
+ * Returns 0 for no match.
10
+ */
11
+
12
+ const SCORE_EXACT_PREFIX = 1000;
13
+ const SCORE_SUBSTRING = 500;
14
+ const SCORE_FUZZY_BASE = 100;
15
+
16
+ /**
17
+ * Calculate fuzzy match score between query and target.
18
+ * Higher score = better match. 0 = no match.
19
+ */
20
+ export function fuzzyMatch(query: string, target: string): number {
21
+ if (!query || !target) return 0;
22
+
23
+ const q = query.toLowerCase();
24
+ const t = target.toLowerCase();
25
+
26
+ // Exact prefix match (highest priority)
27
+ if (t.startsWith(q)) {
28
+ // Bonus for exact match
29
+ if (t === q) return SCORE_EXACT_PREFIX + 100;
30
+ return SCORE_EXACT_PREFIX;
31
+ }
32
+
33
+ // Substring match (medium priority)
34
+ if (t.includes(q)) {
35
+ return SCORE_SUBSTRING;
36
+ }
37
+
38
+ // Character-by-character fuzzy match
39
+ // All query characters must appear in order in target
40
+ let targetIdx = 0;
41
+ let matchedChars = 0;
42
+ let consecutiveBonus = 0;
43
+ let lastMatchIdx = -2;
44
+
45
+ for (let i = 0; i < q.length; i++) {
46
+ const char = q[i];
47
+ let found = false;
48
+
49
+ while (targetIdx < t.length) {
50
+ if (t[targetIdx] === char) {
51
+ matchedChars++;
52
+ // Bonus for consecutive matches
53
+ if (targetIdx === lastMatchIdx + 1) {
54
+ consecutiveBonus += 10;
55
+ }
56
+ lastMatchIdx = targetIdx;
57
+ targetIdx++;
58
+ found = true;
59
+ break;
60
+ }
61
+ targetIdx++;
62
+ }
63
+
64
+ if (!found) {
65
+ return 0; // Query char not found, no match
66
+ }
67
+ }
68
+
69
+ // Base score plus bonuses
70
+ // More matched chars and consecutive matches = higher score
71
+ const lengthRatio = matchedChars / t.length;
72
+ return Math.floor(SCORE_FUZZY_BASE * lengthRatio + consecutiveBonus);
73
+ }
74
+
75
+ /**
76
+ * Filter and sort items by fuzzy match score.
77
+ * Returns only items with score > 0, sorted by score descending.
78
+ */
79
+ export function fuzzyFilter<T>(query: string, items: T[], getText: (item: T) => string): T[] {
80
+ if (!query) return items;
81
+
82
+ const scored = items
83
+ .map((item) => ({
84
+ item,
85
+ score: fuzzyMatch(query, getText(item)),
86
+ }))
87
+ .filter(({ score }) => score > 0);
88
+
89
+ // Sort by score descending
90
+ scored.sort((a, b) => b.score - a.score);
91
+
92
+ return scored.map(({ item }) => item);
93
+ }
@@ -15,7 +15,7 @@ import { formatSize } from "./format.ts";
15
15
  import { createFileCountProgress, createUploadProgress } from "./progress.ts";
16
16
  import type { OperationReporter } from "./project-operations.ts";
17
17
  import { getProjectTags } from "./tags.ts";
18
- import { Events, track } from "./telemetry.ts";
18
+ import { Events, track, trackActivationIfFirst } from "./telemetry.ts";
19
19
  import { packageForDeploy } from "./zip-packager.ts";
20
20
 
21
21
  export interface ManagedCreateResult {
@@ -169,8 +169,11 @@ export async function deployCodeToManagedProject(
169
169
  // Track success
170
170
  track(Events.MANAGED_DEPLOY_COMPLETED, {
171
171
  duration_ms: Date.now() - startTime,
172
+ project_id: projectId,
172
173
  });
173
174
 
175
+ await trackActivationIfFirst("managed");
176
+
174
177
  // Fire-and-forget tag sync (non-blocking)
175
178
  getProjectTags(projectPath)
176
179
  .then((tags) => {
@@ -3,12 +3,15 @@
3
3
  * Mirrors BYO down.ts flow but uses control plane APIs.
4
4
  */
5
5
 
6
+ import { mkdirSync } from "node:fs";
6
7
  import { writeFile } from "node:fs/promises";
7
8
  import { join } from "node:path";
9
+ import { getJackHome } from "./config.ts";
8
10
  import {
9
11
  deleteManagedProject,
10
12
  exportManagedDatabase,
11
13
  fetchProjectResources,
14
+ getManagedDatabaseInfo,
12
15
  } from "./control-plane.ts";
13
16
  import { promptSelect } from "./hooks.ts";
14
17
  import { error, info, item, output, success, warn } from "./output.ts";
@@ -20,6 +23,7 @@ export interface ManagedDownFlags {
20
23
  export interface ManagedProjectInfo {
21
24
  projectId: string;
22
25
  runjackUrl: string | null;
26
+ localPath: string | null;
23
27
  }
24
28
 
25
29
  export async function managedDown(
@@ -54,12 +58,21 @@ export async function managedDown(
54
58
  // Interactive mode - fetch actual resources
55
59
  let hasDatabase = false;
56
60
  let databaseName: string | null = null;
61
+ let databaseNumTables = 0;
57
62
  try {
58
63
  const resources = await fetchProjectResources(projectId);
59
64
  const d1Resource = resources.find((r) => r.resource_type === "d1");
60
65
  if (d1Resource) {
61
66
  hasDatabase = true;
62
67
  databaseName = d1Resource.resource_name;
68
+ // Fetch table count to determine if export is needed
69
+ try {
70
+ const dbInfo = await getManagedDatabaseInfo(projectId);
71
+ databaseNumTables = dbInfo.numTables;
72
+ } catch {
73
+ // If we can't get info, assume it has data to be safe
74
+ databaseNumTables = 1;
75
+ }
63
76
  }
64
77
  } catch {
65
78
  // If fetch fails, assume no database (safer than showing wrong info)
@@ -89,11 +102,13 @@ export async function managedDown(
89
102
  return false;
90
103
  }
91
104
 
92
- // Auto-export database if it exists (no prompt)
105
+ // Auto-export database if it has tables (skip empty databases)
93
106
  let exportPath: string | null = null;
94
- if (hasDatabase) {
95
- exportPath = join(process.cwd(), `${projectName}-backup.sql`);
96
- output.start(`Exporting database to ${exportPath}...`);
107
+ if (hasDatabase && databaseNumTables > 0) {
108
+ const backupDir = project.localPath ?? join(getJackHome(), projectName);
109
+ mkdirSync(backupDir, { recursive: true });
110
+ exportPath = join(backupDir, `${projectName}-backup.sql`);
111
+ output.start("Exporting database...");
97
112
 
98
113
  try {
99
114
  const exportResult = await exportManagedDatabase(projectId);
@@ -132,7 +147,7 @@ export async function managedDown(
132
147
  console.error("");
133
148
  success(`Undeployed '${projectName}'`);
134
149
  if (exportPath) {
135
- info(`Backup saved to ./${projectName}-backup.sql`);
150
+ info(`Backup saved to ${exportPath}`);
136
151
  }
137
152
  console.error("");
138
153
  return true;
package/src/lib/output.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import yoctoSpinner from "yocto-spinner";
2
+ import type { OperationReporter } from "./project-operations.ts";
2
3
 
3
4
  const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
4
5
 
6
+ /**
7
+ * Quiet mode detection for AI agents and CI environments.
8
+ * Reduces token usage by simplifying output (no spinners, no decorative boxes).
9
+ */
10
+ export const isQuietMode =
11
+ !process.stderr.isTTY ||
12
+ process.env.CI === "true" ||
13
+ process.env.CI === "1" ||
14
+ process.env.JACK_QUIET === "1";
15
+
5
16
  /**
6
17
  * ANSI color codes for terminal output
7
18
  */
@@ -22,12 +33,19 @@ let currentSpinner: ReturnType<typeof yoctoSpinner> | null = null;
22
33
  */
23
34
  export const output = {
24
35
  start(text: string) {
36
+ if (isQuietMode) {
37
+ console.error(text);
38
+ return;
39
+ }
25
40
  if (currentSpinner) {
26
41
  currentSpinner.stop();
27
42
  }
28
43
  currentSpinner = yoctoSpinner({ text }).start();
29
44
  },
30
45
  stop() {
46
+ if (isQuietMode) {
47
+ return;
48
+ }
31
49
  if (currentSpinner) {
32
50
  currentSpinner.stop();
33
51
  currentSpinner = null;
@@ -46,6 +64,26 @@ export const output = {
46
64
  * Create a spinner for long-running operations
47
65
  */
48
66
  export function spinner(text: string) {
67
+ if (isQuietMode) {
68
+ console.error(text);
69
+ let currentText = text;
70
+ return {
71
+ success(message: string) {
72
+ success(message);
73
+ },
74
+ error(message: string) {
75
+ error(message);
76
+ },
77
+ stop() {},
78
+ get text() {
79
+ return currentText;
80
+ },
81
+ set text(value: string | undefined) {
82
+ currentText = value ?? "";
83
+ },
84
+ };
85
+ }
86
+
49
87
  const spin = yoctoSpinner({ text }).start();
50
88
 
51
89
  return {
@@ -121,7 +159,15 @@ export function getRandomPurple(): string {
121
159
  * Print a boxed message for important info (cyberpunk style)
122
160
  */
123
161
  export function box(title: string, lines: string[]): void {
124
- const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
162
+ if (isQuietMode) {
163
+ return; // Skip decorative boxes in quiet mode
164
+ }
165
+ // Respect terminal width (leave room for box borders + indent)
166
+ const termWidth = process.stderr.columns || process.stdout.columns || 80;
167
+ const maxBoxWidth = Math.max(30, termWidth - 6); // 2 indent + 2 borders + 2 padding
168
+
169
+ const contentMaxLen = Math.max(title.length, ...lines.map((l) => l.length));
170
+ const maxLen = Math.min(contentMaxLen, maxBoxWidth - 4);
125
171
  const innerWidth = maxLen + 4;
126
172
 
127
173
  const purple = isColorEnabled ? getRandomPurple() : "";
@@ -132,10 +178,15 @@ export function box(title: string, lines: string[]): void {
132
178
  const fill = "▓".repeat(innerWidth);
133
179
  const gradient = "░".repeat(innerWidth);
134
180
 
181
+ // Truncate text if too long for box
182
+ const truncate = (text: string) => (text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text);
183
+
135
184
  // Pad plain text first, then apply colors (ANSI codes break padEnd calculation)
136
- const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
137
- const padTitle = (text: string) =>
138
- ` ${bold}${text}${reset}${purple}${" ".repeat(maxLen - text.length)} `;
185
+ const pad = (text: string) => ` ${truncate(text).padEnd(maxLen)} `;
186
+ const padTitle = (text: string) => {
187
+ const t = truncate(text);
188
+ return ` ${bold}${t}${reset}${purple}${" ".repeat(maxLen - t.length)} `;
189
+ };
139
190
 
140
191
  console.error("");
141
192
  console.error(` ${purple}╔${bar}╗${reset}`);
@@ -154,7 +205,15 @@ export function box(title: string, lines: string[]): void {
154
205
  * Print a celebration box (for final success after setup)
155
206
  */
156
207
  export function celebrate(title: string, lines: string[]): void {
157
- const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
208
+ if (isQuietMode) {
209
+ return; // Skip decorative boxes in quiet mode
210
+ }
211
+ // Respect terminal width (leave room for box borders + indent)
212
+ const termWidth = process.stderr.columns || process.stdout.columns || 80;
213
+ const maxBoxWidth = Math.max(30, termWidth - 6); // 2 indent + 2 borders + 2 padding
214
+
215
+ const contentMaxLen = Math.max(title.length, ...lines.map((l) => l.length));
216
+ const maxLen = Math.min(contentMaxLen, maxBoxWidth - 4);
158
217
  const innerWidth = maxLen + 4;
159
218
 
160
219
  const purple = isColorEnabled ? getRandomPurple() : "";
@@ -166,12 +225,16 @@ export function celebrate(title: string, lines: string[]): void {
166
225
  const gradient = "░".repeat(innerWidth);
167
226
  const space = " ".repeat(innerWidth);
168
227
 
228
+ // Truncate text if too long for box
229
+ const truncate = (text: string) => (text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text);
230
+
169
231
  // Center text based on visual length, then apply colors
170
232
  const center = (text: string, applyBold = false) => {
171
- const left = Math.floor((innerWidth - text.length) / 2);
172
- const right = innerWidth - text.length - left;
173
- const centered = " ".repeat(left) + text + " ".repeat(right);
174
- return applyBold ? centered.replace(text, bold + text + reset + purple) : centered;
233
+ const t = truncate(text);
234
+ const left = Math.floor((innerWidth - t.length) / 2);
235
+ const right = innerWidth - t.length - left;
236
+ const centered = " ".repeat(left) + t + " ".repeat(right);
237
+ return applyBold ? centered.replace(t, bold + t + reset + purple) : centered;
175
238
  };
176
239
 
177
240
  console.error("");
@@ -188,3 +251,21 @@ export function celebrate(title: string, lines: string[]): void {
188
251
  console.error(` ${purple}╚${bar}╝${reset}`);
189
252
  console.error("");
190
253
  }
254
+
255
+ /**
256
+ * Create a standard reporter object for project operations.
257
+ * Respects quiet mode for reduced output in CI/agent environments.
258
+ */
259
+ export function createReporter(): OperationReporter {
260
+ return {
261
+ start: output.start,
262
+ stop: output.stop,
263
+ spinner,
264
+ info,
265
+ warn,
266
+ error,
267
+ success,
268
+ box,
269
+ celebrate,
270
+ };
271
+ }