@getjack/jack 0.1.22 → 0.1.23

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/commands/clone.ts +5 -5
  3. package/src/commands/down.ts +44 -69
  4. package/src/commands/link.ts +9 -6
  5. package/src/commands/new.ts +54 -76
  6. package/src/commands/publish.ts +7 -2
  7. package/src/commands/secrets.ts +2 -1
  8. package/src/commands/services.ts +41 -15
  9. package/src/commands/update.ts +2 -2
  10. package/src/index.ts +43 -2
  11. package/src/lib/agent-integration.ts +217 -0
  12. package/src/lib/auth/login-flow.ts +2 -1
  13. package/src/lib/binding-validator.ts +2 -3
  14. package/src/lib/build-helper.ts +7 -1
  15. package/src/lib/hooks.ts +101 -21
  16. package/src/lib/managed-down.ts +32 -55
  17. package/src/lib/project-detection.ts +48 -21
  18. package/src/lib/project-operations.ts +31 -13
  19. package/src/lib/prompts.ts +16 -23
  20. package/src/lib/services/db-execute.ts +39 -0
  21. package/src/lib/services/sql-classifier.test.ts +2 -2
  22. package/src/lib/services/sql-classifier.ts +5 -4
  23. package/src/lib/version-check.ts +15 -10
  24. package/src/lib/zip-packager.ts +16 -0
  25. package/src/mcp/resources/index.ts +42 -2
  26. package/src/templates/index.ts +63 -3
  27. package/templates/ai-chat/.jack.json +29 -0
  28. package/templates/ai-chat/bun.lock +18 -0
  29. package/templates/ai-chat/package.json +14 -0
  30. package/templates/ai-chat/public/chat.js +149 -0
  31. package/templates/ai-chat/public/index.html +209 -0
  32. package/templates/ai-chat/src/index.ts +105 -0
  33. package/templates/ai-chat/wrangler.jsonc +12 -0
  34. package/templates/semantic-search/.jack.json +26 -0
  35. package/templates/semantic-search/bun.lock +18 -0
  36. package/templates/semantic-search/package.json +12 -0
  37. package/templates/semantic-search/public/app.js +120 -0
  38. package/templates/semantic-search/public/index.html +210 -0
  39. package/templates/semantic-search/schema.sql +5 -0
  40. package/templates/semantic-search/src/index.ts +144 -0
  41. package/templates/semantic-search/tsconfig.json +13 -0
  42. package/templates/semantic-search/wrangler.jsonc +27 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Agent integration module
3
+ *
4
+ * Ensures AI agents have proper context for jack projects.
5
+ * Called during both project creation and first BYO deploy.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { Template } from "../templates/types.ts";
11
+ import { installMcpConfigsToAllApps, isAppInstalled } from "./mcp-config.ts";
12
+
13
+ export interface EnsureAgentResult {
14
+ mcpInstalled: string[];
15
+ jackMdCreated: boolean;
16
+ referencesAdded: string[];
17
+ }
18
+
19
+ export interface EnsureAgentOptions {
20
+ template?: Template;
21
+ silent?: boolean;
22
+ projectName?: string;
23
+ }
24
+
25
+ /**
26
+ * Generate JACK.md content
27
+ * Uses template agentContext if available, otherwise generic jack instructions
28
+ */
29
+ export function generateJackMd(projectName?: string, template?: Template): string {
30
+ const header = projectName ? `# ${projectName}\n\n` : "# Jack\n\n";
31
+
32
+ const templateSummary = template?.agentContext?.summary;
33
+ const templateFullText = template?.agentContext?.full_text;
34
+
35
+ const summarySection = templateSummary ? `> ${templateSummary}\n\n` : "";
36
+
37
+ const templateSection = templateFullText ? `${templateFullText}\n\n` : "";
38
+
39
+ return `${header}${summarySection}This project is deployed and managed via jack.
40
+
41
+ ## Quick Commands
42
+
43
+ | Command | What it does |
44
+ |---------|--------------|
45
+ | \`jack ship\` | Deploy to production |
46
+ | \`jack logs\` | Stream live logs |
47
+ | \`jack services\` | Manage databases, KV, and other bindings |
48
+ | \`jack secrets\` | Manage environment secrets |
49
+
50
+ ## Important
51
+
52
+ - **Never run \`wrangler\` commands directly** - jack handles all infrastructure
53
+ - Use \`jack services db\` to create and query databases
54
+ - Secrets sync automatically across deploys
55
+
56
+ ## Services & Bindings
57
+
58
+ Jack manages your project's services. To add a database:
59
+
60
+ \`\`\`bash
61
+ jack services db create
62
+ \`\`\`
63
+
64
+ To query it:
65
+
66
+ \`\`\`bash
67
+ jack services db query "SELECT * FROM users"
68
+ \`\`\`
69
+
70
+ More bindings (KV, R2, queues) coming soon.
71
+
72
+ ${templateSection}## For AI Agents
73
+
74
+ ### MCP Tools
75
+
76
+ If jack MCP is connected, prefer these tools over CLI commands:
77
+
78
+ | Tool | Use for |
79
+ |------|---------|
80
+ | \`mcp__jack__deploy_project\` | Deploy changes |
81
+ | \`mcp__jack__create_database\` | Create a new database |
82
+ | \`mcp__jack__execute_sql\` | Query the database |
83
+ | \`mcp__jack__list_projects\` | List all projects |
84
+ | \`mcp__jack__get_project_status\` | Check deployment status |
85
+
86
+ ### Documentation
87
+
88
+ Full jack documentation: https://docs.getjack.org/llms-full.txt
89
+ `;
90
+ }
91
+
92
+ /**
93
+ * Create JACK.md if it doesn't exist
94
+ */
95
+ async function ensureJackMd(
96
+ projectPath: string,
97
+ projectName?: string,
98
+ template?: Template,
99
+ ): Promise<boolean> {
100
+ const jackMdPath = join(projectPath, "JACK.md");
101
+
102
+ if (existsSync(jackMdPath)) {
103
+ return false;
104
+ }
105
+
106
+ const content = generateJackMd(projectName, template);
107
+ await Bun.write(jackMdPath, content);
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Append JACK.md reference to existing agent files (CLAUDE.md, AGENTS.md)
113
+ */
114
+ async function appendJackMdReferences(projectPath: string): Promise<string[]> {
115
+ const filesToCheck = ["CLAUDE.md", "AGENTS.md"];
116
+ const referencesAdded: string[] = [];
117
+ const jackMdPath = join(projectPath, "JACK.md");
118
+
119
+ // Only add references if JACK.md exists
120
+ if (!existsSync(jackMdPath)) {
121
+ return referencesAdded;
122
+ }
123
+
124
+ const referenceBlock = `<!-- Added by jack -->
125
+ > **Jack project** - See [JACK.md](./JACK.md) for deployment, services, and bindings.
126
+
127
+ `;
128
+
129
+ for (const filename of filesToCheck) {
130
+ const filePath = join(projectPath, filename);
131
+
132
+ if (!existsSync(filePath)) {
133
+ continue;
134
+ }
135
+
136
+ try {
137
+ const content = await Bun.file(filePath).text();
138
+
139
+ // Skip if reference already exists
140
+ if (content.includes("JACK.md") || content.includes("<!-- Added by jack -->")) {
141
+ continue;
142
+ }
143
+
144
+ // Find position after first heading, or prepend if no heading
145
+ const headingMatch = content.match(/^#[^\n]*\n/m);
146
+ let newContent: string;
147
+
148
+ if (headingMatch && headingMatch.index !== undefined) {
149
+ const insertPos = headingMatch.index + headingMatch[0].length;
150
+ newContent =
151
+ content.slice(0, insertPos) + "\n" + referenceBlock + content.slice(insertPos);
152
+ } else {
153
+ newContent = referenceBlock + content;
154
+ }
155
+
156
+ await Bun.write(filePath, newContent);
157
+ referencesAdded.push(filename);
158
+ } catch {
159
+ // Ignore errors reading/writing individual files
160
+ }
161
+ }
162
+
163
+ return referencesAdded;
164
+ }
165
+
166
+ /**
167
+ * Ensure MCP is configured for detected AI apps
168
+ * Returns list of apps that were configured
169
+ */
170
+ async function ensureMcpConfigured(): Promise<string[]> {
171
+ // Only attempt if at least one supported app is installed
172
+ const hasClaudeCode = isAppInstalled("claude-code");
173
+ const hasClaudeDesktop = isAppInstalled("claude-desktop");
174
+
175
+ if (!hasClaudeCode && !hasClaudeDesktop) {
176
+ return [];
177
+ }
178
+
179
+ try {
180
+ return await installMcpConfigsToAllApps();
181
+ } catch {
182
+ // Don't fail if MCP install fails
183
+ return [];
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Ensure agent integration is set up for a project
189
+ *
190
+ * This function:
191
+ * 1. Creates JACK.md if not exists (with template context if available)
192
+ * 2. Appends JACK.md reference to existing CLAUDE.md/AGENTS.md
193
+ * 3. Installs MCP config to detected AI apps
194
+ *
195
+ * Safe to call multiple times - all operations are idempotent.
196
+ */
197
+ export async function ensureAgentIntegration(
198
+ projectPath: string,
199
+ options: EnsureAgentOptions = {},
200
+ ): Promise<EnsureAgentResult> {
201
+ const { template, projectName } = options;
202
+
203
+ // 1. Create JACK.md if not exists
204
+ const jackMdCreated = await ensureJackMd(projectPath, projectName, template);
205
+
206
+ // 2. Append references to existing agent files
207
+ const referencesAdded = await appendJackMdReferences(projectPath);
208
+
209
+ // 3. Ensure MCP is configured
210
+ const mcpInstalled = await ensureMcpConfigured();
211
+
212
+ return {
213
+ mcpInstalled,
214
+ jackMdCreated,
215
+ referencesAdded,
216
+ };
217
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Shared login flow for CLI and programmatic use
3
3
  */
4
- import { isCancel, text } from "@clack/prompts";
4
+ import { text } from "@clack/prompts";
5
+ import { isCancel } from "../hooks.ts";
5
6
  import {
6
7
  checkUsernameAvailable,
7
8
  getCurrentUserProfile,
@@ -19,6 +19,7 @@ export const SUPPORTED_BINDINGS = [
19
19
  "vars",
20
20
  "r2_buckets",
21
21
  "kv_namespaces",
22
+ "vectorize",
22
23
  ] as const;
23
24
 
24
25
  /**
@@ -30,7 +31,6 @@ export const UNSUPPORTED_BINDINGS = [
30
31
  "queues",
31
32
  "services",
32
33
  "hyperdrive",
33
- "vectorize",
34
34
  "browser",
35
35
  "mtls_certificates",
36
36
  ] as const;
@@ -43,7 +43,6 @@ const BINDING_DISPLAY_NAMES: Record<string, string> = {
43
43
  queues: "Queues",
44
44
  services: "Service Bindings",
45
45
  hyperdrive: "Hyperdrive",
46
- vectorize: "Vectorize",
47
46
  browser: "Browser Rendering",
48
47
  mtls_certificates: "mTLS Certificates",
49
48
  };
@@ -72,7 +71,7 @@ export function validateBindings(
72
71
  if (value !== undefined && value !== null) {
73
72
  const displayName = BINDING_DISPLAY_NAMES[binding] || binding;
74
73
  errors.push(
75
- `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, KV, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
74
+ `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, KV, Vectorize, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
76
75
  );
77
76
  }
78
77
  }
@@ -57,12 +57,18 @@ export interface WranglerConfig {
57
57
  binding: string;
58
58
  id?: string; // Optional - wrangler auto-provisions if missing
59
59
  }>;
60
+ vectorize?: Array<{
61
+ binding: string;
62
+ index_name?: string;
63
+ preset?: "cloudflare" | "cloudflare-small" | "cloudflare-large";
64
+ dimensions?: number;
65
+ metric?: "cosine" | "euclidean" | "dot-product";
66
+ }>;
60
67
  // Unsupported bindings (for validation)
61
68
  durable_objects?: unknown;
62
69
  queues?: unknown;
63
70
  services?: unknown;
64
71
  hyperdrive?: unknown;
65
- vectorize?: unknown;
66
72
  browser?: unknown;
67
73
  mtls_certificates?: unknown;
68
74
  }
package/src/lib/hooks.ts CHANGED
@@ -23,14 +23,13 @@ async function readMultilineJson(prompt: string): Promise<string> {
23
23
 
24
24
  return new Promise((resolve) => {
25
25
  rl.on("line", (line) => {
26
- if (line.trim() === "" && lines.length > 0) {
26
+ // Empty line = submit (or skip if nothing entered)
27
+ if (line.trim() === "") {
27
28
  rl.close();
28
29
  resolve(lines.join("\n"));
29
30
  return;
30
31
  }
31
- if (line.trim() !== "") {
32
- lines.push(line);
33
- }
32
+ lines.push(line);
34
33
  });
35
34
 
36
35
  rl.on("close", () => {
@@ -82,31 +81,112 @@ const noopOutput: HookOutput = {
82
81
  * - writeJson: { path, set, successMessage? } -> JSON update (runs in non-interactive)
83
82
  */
84
83
 
84
+ // Re-export isCancel for consumers
85
+ export { isCancel } from "@clack/core";
86
+
87
+ // Unicode symbols (with ASCII fallbacks for non-unicode terminals)
88
+ const isUnicodeSupported =
89
+ process.platform !== "win32" ||
90
+ Boolean(process.env.CI) ||
91
+ Boolean(process.env.WT_SESSION) ||
92
+ process.env.TERM_PROGRAM === "vscode" ||
93
+ process.env.TERM === "xterm-256color";
94
+
95
+ const S_RADIO_ACTIVE = isUnicodeSupported ? "●" : ">";
96
+ const S_RADIO_INACTIVE = isUnicodeSupported ? "○" : " ";
97
+
98
+ export interface SelectOption<T = string> {
99
+ value: T;
100
+ label: string;
101
+ hint?: string;
102
+ }
103
+
85
104
  /**
86
- * Prompt user with numbered options
87
- * Uses @clack/prompts selectKey for reliable input handling
88
- * Returns the selected option index (0-based) or -1 if cancelled
105
+ * Clean select prompt with bullet-point style (no vertical bars)
106
+ * Supports both:
107
+ * - Number keys (1, 2, 3...) for immediate selection
108
+ * - Arrow keys (up/down) + Enter for navigation-based selection
109
+ *
110
+ * @param message - The prompt message to display
111
+ * @param options - Array of options (strings or {value, label, hint?} objects)
112
+ * @returns The selected value, or symbol if cancelled
89
113
  */
90
- export async function promptSelect(options: string[]): Promise<number> {
91
- const { selectKey, isCancel } = await import("@clack/prompts");
92
-
93
- // Build options with number keys (1, 2, 3, ...)
94
- const clackOptions = options.map((label, index) => ({
95
- value: String(index + 1),
96
- label,
97
- }));
98
-
99
- const result = await selectKey({
100
- message: "",
101
- options: clackOptions,
114
+ export async function promptSelectValue<T>(
115
+ message: string,
116
+ options: Array<SelectOption<T> | string>,
117
+ ): Promise<T | symbol> {
118
+ const { SelectPrompt, isCancel } = await import("@clack/core");
119
+ const pc = await import("picocolors");
120
+
121
+ // Normalize options to {value, label} format
122
+ const normalizedOptions = options.map((opt, index) => {
123
+ if (typeof opt === "string") {
124
+ return { value: opt as T, label: opt, key: String(index + 1) };
125
+ }
126
+ return { ...opt, key: String(index + 1) };
127
+ });
128
+
129
+ const prompt = new SelectPrompt({
130
+ options: normalizedOptions,
131
+ initialValue: normalizedOptions[0]?.value,
132
+ render() {
133
+ const title = `${message}\n`;
134
+ const lines: string[] = [];
135
+
136
+ for (let i = 0; i < normalizedOptions.length; i++) {
137
+ const opt = normalizedOptions[i];
138
+ const isActive = this.cursor === i;
139
+ const num = `${i + 1}.`;
140
+
141
+ if (isActive) {
142
+ const hint = opt.hint ? pc.dim(` (${opt.hint})`) : "";
143
+ lines.push(`${pc.green(S_RADIO_ACTIVE)} ${num} ${opt.label}${hint}`);
144
+ } else {
145
+ lines.push(`${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(num)} ${pc.dim(opt.label)}`);
146
+ }
147
+ }
148
+
149
+ return title + lines.join("\n");
150
+ },
102
151
  });
103
152
 
153
+ // Add number key support for immediate selection
154
+ prompt.on("key", (char) => {
155
+ if (!char) return;
156
+ const num = Number.parseInt(char, 10);
157
+ if (num >= 1 && num <= normalizedOptions.length) {
158
+ prompt.value = normalizedOptions[num - 1]?.value;
159
+ prompt.emit("submit");
160
+ }
161
+ });
162
+
163
+ const result = await prompt.prompt();
164
+
165
+ if (isCancel(result)) {
166
+ return result;
167
+ }
168
+
169
+ return result as T;
170
+ }
171
+
172
+ /**
173
+ * Simple select prompt for string options (returns index)
174
+ * Supports both number keys (1, 2...) and arrow keys + Enter
175
+ * Returns the selected option index (0-based) or -1 if cancelled
176
+ */
177
+ export async function promptSelect(options: string[], message?: string): Promise<number> {
178
+ const { isCancel } = await import("@clack/core");
179
+
180
+ const result = await promptSelectValue(
181
+ message ?? "",
182
+ options.map((label, index) => ({ value: index, label })),
183
+ );
184
+
104
185
  if (isCancel(result)) {
105
186
  return -1;
106
187
  }
107
188
 
108
- // Convert "1", "2", etc. back to 0-based index
109
- return Number.parseInt(result as string, 10) - 1;
189
+ return result as number;
110
190
  }
111
191
 
112
192
  /**
@@ -76,9 +76,12 @@ export async function managedDown(
76
76
  }
77
77
  console.error("");
78
78
 
79
- // Confirm undeploy
79
+ // Single confirmation with clear description
80
80
  console.error("");
81
- info("Undeploy this project?");
81
+ const confirmMsg = hasDatabase
82
+ ? "Undeploy this project? All resources will be deleted."
83
+ : "Undeploy this project?";
84
+ info(confirmMsg);
82
85
  const action = await promptSelect(["Yes", "No"]);
83
86
 
84
87
  if (action !== 0) {
@@ -86,77 +89,51 @@ export async function managedDown(
86
89
  return false;
87
90
  }
88
91
 
89
- // Ask about database export (only if database exists)
92
+ // Auto-export database if it exists (no prompt)
93
+ let exportPath: string | null = null;
90
94
  if (hasDatabase) {
91
- console.error("");
92
- info("Database will be deleted with the project");
95
+ exportPath = join(process.cwd(), `${projectName}-backup.sql`);
96
+ output.start(`Exporting database to ${exportPath}...`);
93
97
 
94
- console.error("");
95
- info("Export database before deleting?");
96
- const exportAction = await promptSelect(["Yes", "No"]);
97
-
98
- if (exportAction === 0) {
99
- const exportPath = join(process.cwd(), `${projectName}-backup.sql`);
100
- output.start(`Exporting database to ${exportPath}...`);
101
-
102
- try {
103
- const exportResult = await exportManagedDatabase(projectId);
104
-
105
- // Download the SQL file
106
- const response = await fetch(exportResult.download_url);
107
- if (!response.ok) {
108
- throw new Error(`Failed to download export: ${response.statusText}`);
109
- }
110
-
111
- const sqlContent = await response.text();
112
- await writeFile(exportPath, sqlContent, "utf-8");
113
-
114
- output.stop();
115
- success(`Database exported to ${exportPath}`);
116
- } catch (err) {
117
- output.stop();
118
- error(`Failed to export database: ${err instanceof Error ? err.message : String(err)}`);
119
-
120
- // If export times out, abort
121
- if (err instanceof Error && err.message.includes("timed out")) {
122
- error("Export timeout - deletion aborted");
123
- return false;
124
- }
125
-
126
- console.error("");
127
- info("Continue without exporting?");
128
- const continueAction = await promptSelect(["Yes", "No"]);
129
-
130
- if (continueAction !== 0) {
131
- info("Cancelled");
132
- return false;
133
- }
98
+ try {
99
+ const exportResult = await exportManagedDatabase(projectId);
100
+
101
+ // Download the SQL file
102
+ const response = await fetch(exportResult.download_url);
103
+ if (!response.ok) {
104
+ throw new Error(`Failed to download export: ${response.statusText}`);
134
105
  }
106
+
107
+ const sqlContent = await response.text();
108
+ await writeFile(exportPath, sqlContent, "utf-8");
109
+
110
+ output.stop();
111
+ } catch (err) {
112
+ output.stop();
113
+ warn(`Could not export database: ${err instanceof Error ? err.message : String(err)}`);
114
+ exportPath = null;
135
115
  }
136
116
  }
137
117
 
138
118
  // Execute deletion
139
- console.error("");
140
- info("Executing cleanup...");
141
- console.error("");
142
-
143
119
  output.start("Undeploying from jack cloud...");
144
120
  try {
145
121
  const result = await deleteManagedProject(projectId);
146
122
  output.stop();
147
- success(`'${projectName}' undeployed`);
148
123
 
149
- // Report resource results
124
+ // Report any resource deletion failures
150
125
  for (const resource of result.resources) {
151
- if (resource.success) {
152
- success(`Deleted ${resource.resource}`);
153
- } else {
126
+ if (!resource.success) {
154
127
  warn(`Failed to delete ${resource.resource}: ${resource.error}`);
155
128
  }
156
129
  }
157
130
 
131
+ // Final success message
158
132
  console.error("");
159
- success(`Project '${projectName}' undeployed`);
133
+ success(`Undeployed '${projectName}'`);
134
+ if (exportPath) {
135
+ info(`Backup saved to ./${projectName}-backup.sql`);
136
+ }
160
137
  console.error("");
161
138
  return true;
162
139
  } catch (err) {
@@ -34,6 +34,7 @@ interface PackageJson {
34
34
  name?: string;
35
35
  dependencies?: Record<string, string>;
36
36
  devDependencies?: Record<string, string>;
37
+ workspaces?: string[] | { packages: string[] };
37
38
  }
38
39
 
39
40
  const CONFIG_EXTENSIONS = [".ts", ".js", ".mjs"];
@@ -212,6 +213,16 @@ export function detectProjectType(projectPath: string): DetectionResult {
212
213
  const detectedDeps: string[] = [];
213
214
  const configFiles: string[] = [];
214
215
 
216
+ // Check for monorepo - user is in wrong directory
217
+ if (pkg?.workspaces) {
218
+ const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages;
219
+ const hint = workspaces.length > 0 ? workspaces[0]?.replace("/*", "/your-app") : "apps/your-app";
220
+ return {
221
+ type: "unknown",
222
+ error: `This is a monorepo root, not a deployable project.\n\ncd into a package first:\n cd ${hint}\n jack ship`,
223
+ };
224
+ }
225
+
215
226
  // Check for unsupported/coming-soon frameworks first (before reading package.json)
216
227
  // This provides better error messages for frameworks we recognize but don't auto-deploy yet
217
228
  const unsupported = detectUnsupportedFramework(projectPath, pkg);
@@ -363,6 +374,11 @@ function shouldExclude(relativePath: string): boolean {
363
374
  return false;
364
375
  }
365
376
 
377
+ // Derive skip directories from DEFAULT_EXCLUDES patterns (e.g., "node_modules/**" -> "node_modules")
378
+ const SKIP_DIRECTORIES = new Set(
379
+ DEFAULT_EXCLUDES.filter((p) => p.endsWith("/**")).map((p) => p.slice(0, -3)),
380
+ );
381
+
366
382
  export async function validateProject(projectPath: string): Promise<ValidationResult> {
367
383
  if (!existsSync(join(projectPath, "package.json"))) {
368
384
  return {
@@ -374,37 +390,41 @@ export async function validateProject(projectPath: string): Promise<ValidationRe
374
390
  let fileCount = 0;
375
391
  let totalSizeBytes = 0;
376
392
 
377
- try {
378
- const entries = await readdir(projectPath, {
379
- recursive: true,
380
- withFileTypes: true,
381
- });
393
+ // Walk directory tree manually to skip excluded dirs early (don't descend into node_modules)
394
+ async function walkDir(dirPath: string): Promise<void> {
395
+ const entries = await readdir(dirPath, { withFileTypes: true });
382
396
 
383
397
  for (const entry of entries) {
384
- if (!entry.isFile()) {
398
+ // Skip excluded directories entirely (don't descend)
399
+ if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) {
385
400
  continue;
386
401
  }
387
402
 
388
- const parentDir = entry.parentPath ?? projectPath;
389
- const absolutePath = join(parentDir, entry.name);
403
+ const absolutePath = join(dirPath, entry.name);
390
404
  const relativePath = relative(projectPath, absolutePath);
391
405
 
392
- if (shouldExclude(relativePath)) {
393
- continue;
394
- }
406
+ if (entry.isDirectory()) {
407
+ // Recursively walk subdirectories
408
+ await walkDir(absolutePath);
409
+ } else if (entry.isFile()) {
410
+ // Check glob-based exclusions for files
411
+ if (shouldExclude(relativePath)) {
412
+ continue;
413
+ }
395
414
 
396
- fileCount++;
397
- if (fileCount > MAX_FILES) {
398
- return {
399
- valid: false,
400
- error: `Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`,
401
- fileCount,
402
- };
403
- }
415
+ fileCount++;
416
+ if (fileCount > MAX_FILES) {
417
+ throw new Error(`Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`);
418
+ }
404
419
 
405
- const stats = await stat(absolutePath);
406
- totalSizeBytes += stats.size;
420
+ const stats = await stat(absolutePath);
421
+ totalSizeBytes += stats.size;
422
+ }
407
423
  }
424
+ }
425
+
426
+ try {
427
+ await walkDir(projectPath);
408
428
 
409
429
  const totalSizeKb = Math.round(totalSizeBytes / 1024);
410
430
 
@@ -423,6 +443,13 @@ export async function validateProject(projectPath: string): Promise<ValidationRe
423
443
  totalSizeKb,
424
444
  };
425
445
  } catch (err) {
446
+ if (err instanceof Error && err.message.includes("more than")) {
447
+ return {
448
+ valid: false,
449
+ error: err.message,
450
+ fileCount,
451
+ };
452
+ }
426
453
  return {
427
454
  valid: false,
428
455
  error: `Failed to scan project: ${err instanceof Error ? err.message : String(err)}`,