@getjack/jack 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ import { Glob } from "bun";
5
+ import { DEFAULT_EXCLUDES } from "./storage/file-filter.ts";
6
+
7
+ export type ProjectType = "vite" | "sveltekit" | "hono" | "unknown";
8
+
9
+ export interface DetectionResult {
10
+ type: ProjectType;
11
+ configFile?: string;
12
+ entryPoint?: string;
13
+ error?: string;
14
+ unsupportedFramework?:
15
+ | "nextjs"
16
+ | "astro"
17
+ | "nuxt"
18
+ | "remix"
19
+ | "react-router"
20
+ | "tanstack-start"
21
+ | "tauri";
22
+ detectedDeps?: string[];
23
+ configFiles?: string[];
24
+ }
25
+
26
+ export interface ValidationResult {
27
+ valid: boolean;
28
+ error?: string;
29
+ fileCount?: number;
30
+ totalSizeKb?: number;
31
+ }
32
+
33
+ interface PackageJson {
34
+ name?: string;
35
+ dependencies?: Record<string, string>;
36
+ devDependencies?: Record<string, string>;
37
+ }
38
+
39
+ const CONFIG_EXTENSIONS = [".ts", ".js", ".mjs"];
40
+ const HONO_ENTRY_CANDIDATES = [
41
+ "src/index.ts",
42
+ "src/index.js",
43
+ "index.ts",
44
+ "index.js",
45
+ "src/server.ts",
46
+ "src/server.js",
47
+ ];
48
+
49
+ const MAX_FILES = 1000;
50
+ const MAX_SIZE_KB = 50 * 1024; // 50MB in KB
51
+
52
+ function hasDep(pkg: PackageJson, name: string): boolean {
53
+ return !!(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
54
+ }
55
+
56
+ function hasConfigFile(projectPath: string, baseName: string): string | null {
57
+ for (const ext of CONFIG_EXTENSIONS) {
58
+ const configPath = join(projectPath, `${baseName}${ext}`);
59
+ if (existsSync(configPath)) {
60
+ return `${baseName}${ext}`;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function readPackageJson(projectPath: string): PackageJson | null {
67
+ const packageJsonPath = join(projectPath, "package.json");
68
+ if (!existsSync(packageJsonPath)) {
69
+ return null;
70
+ }
71
+ try {
72
+ return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Error messages with helpful setup instructions for each framework
79
+ const FRAMEWORK_SETUP_MESSAGES: Record<string, string> = {
80
+ nextjs: `Next.js project detected!
81
+
82
+ Next.js support requires OpenNext. For now, set up manually:
83
+ 1. bun add @opennextjs/cloudflare
84
+ 2. Create open-next.config.ts
85
+ 3. Create wrangler.jsonc manually
86
+
87
+ Docs: https://opennext.js.org/cloudflare`,
88
+
89
+ astro: `Astro project detected!
90
+
91
+ Auto-deploy coming soon. For now, set up manually:
92
+ 1. bun add @astrojs/cloudflare
93
+ 2. Configure adapter in astro.config.mjs
94
+ 3. Create wrangler.jsonc with main: "dist/_worker.js"
95
+
96
+ Docs: https://docs.astro.build/en/guides/deploy/cloudflare/
97
+ Want this supported? Run: jack feedback`,
98
+
99
+ nuxt: `Nuxt project detected!
100
+
101
+ Auto-deploy coming soon. For now, set up manually:
102
+ 1. Set nitro.preset to 'cloudflare' in nuxt.config.ts
103
+ 2. Run: NITRO_PRESET=cloudflare bunx nuxt build
104
+ 3. Create wrangler.jsonc with main: ".output/server/index.mjs"
105
+
106
+ Docs: https://nuxt.com/deploy/cloudflare
107
+ Want this supported? Run: jack feedback`,
108
+
109
+ remix: `Remix project detected!
110
+
111
+ Remix has been superseded by React Router v7. Consider migrating:
112
+ https://reactrouter.com/upgrading/remix
113
+
114
+ Or set up Remix for Cloudflare manually:
115
+ 1. Use @remix-run/cloudflare adapter
116
+ 2. Create wrangler.jsonc manually`,
117
+
118
+ "react-router": `React Router v7 project detected!
119
+
120
+ Auto-deploy coming soon. For now, set up manually:
121
+ 1. bun add @react-router/cloudflare @cloudflare/vite-plugin
122
+ 2. Configure cloudflare() plugin in vite.config.ts
123
+ 3. Create wrangler.jsonc with main: "build/server/index.js"
124
+
125
+ Docs: https://reactrouter.com/deploying/cloudflare
126
+ Want this supported? Run: jack feedback`,
127
+
128
+ "tanstack-start": `TanStack Start project detected!
129
+
130
+ Auto-deploy coming soon. For now, set up manually:
131
+ 1. bun add -d @cloudflare/vite-plugin
132
+ 2. Configure cloudflare() plugin in vite.config.ts
133
+ 3. Set target: 'cloudflare-module' in tanstackStart() config
134
+ 4. Create wrangler.jsonc with main: ".output/server/index.mjs"
135
+
136
+ Docs: https://tanstack.com/start/latest/docs/framework/react/hosting#cloudflare
137
+ Want this supported? Run: jack feedback`,
138
+
139
+ tauri: `Tauri desktop app detected!
140
+
141
+ Tauri apps are native desktop applications and cannot be deployed to Cloudflare Workers.
142
+ jack is for web apps that run in the browser or on the edge.
143
+
144
+ If you have a web frontend in this project, consider deploying it separately.`,
145
+ };
146
+
147
+ type UnsupportedFramework =
148
+ | "nextjs"
149
+ | "astro"
150
+ | "nuxt"
151
+ | "remix"
152
+ | "react-router"
153
+ | "tanstack-start"
154
+ | "tauri";
155
+
156
+ function detectUnsupportedFramework(
157
+ projectPath: string,
158
+ pkg: PackageJson | null,
159
+ ): UnsupportedFramework | null {
160
+ // Next.js - check config file
161
+ if (hasConfigFile(projectPath, "next.config")) {
162
+ return "nextjs";
163
+ }
164
+
165
+ // Astro - check config file AND dependency (to avoid false positives)
166
+ if (hasConfigFile(projectPath, "astro.config") && pkg && hasDep(pkg, "astro")) {
167
+ return "astro";
168
+ }
169
+
170
+ // React Router v7 - check for @react-router/dev (BEFORE checking Nuxt to avoid conflicts)
171
+ if (pkg && hasDep(pkg, "@react-router/dev")) {
172
+ return "react-router";
173
+ }
174
+
175
+ // TanStack Start - check for @tanstack/react-start or @tanstack/start
176
+ if (pkg && (hasDep(pkg, "@tanstack/react-start") || hasDep(pkg, "@tanstack/start"))) {
177
+ return "tanstack-start";
178
+ }
179
+
180
+ // Nuxt - check config file AND dependency
181
+ if (hasConfigFile(projectPath, "nuxt.config") && pkg && hasDep(pkg, "nuxt")) {
182
+ return "nuxt";
183
+ }
184
+
185
+ // Legacy Remix (not React Router v7)
186
+ if (pkg && (hasDep(pkg, "@remix-run/node") || hasDep(pkg, "@remix-run/react"))) {
187
+ return "remix";
188
+ }
189
+
190
+ // Tauri - desktop app framework (check for src-tauri dir or tauri deps)
191
+ if (
192
+ existsSync(join(projectPath, "src-tauri")) ||
193
+ (pkg && (hasDep(pkg, "@tauri-apps/cli") || hasDep(pkg, "@tauri-apps/api")))
194
+ ) {
195
+ return "tauri";
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ function findHonoEntry(projectPath: string): string | null {
202
+ for (const candidate of HONO_ENTRY_CANDIDATES) {
203
+ if (existsSync(join(projectPath, candidate))) {
204
+ return candidate;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ export function detectProjectType(projectPath: string): DetectionResult {
211
+ const pkg = readPackageJson(projectPath);
212
+ const detectedDeps: string[] = [];
213
+ const configFiles: string[] = [];
214
+
215
+ // Check for unsupported/coming-soon frameworks first (before reading package.json)
216
+ // This provides better error messages for frameworks we recognize but don't auto-deploy yet
217
+ const unsupported = detectUnsupportedFramework(projectPath, pkg);
218
+ if (unsupported) {
219
+ // Collect detected config files for reporting
220
+ const configFileMap: Record<string, string> = {
221
+ nextjs: "next.config",
222
+ astro: "astro.config",
223
+ nuxt: "nuxt.config",
224
+ };
225
+
226
+ if (configFileMap[unsupported]) {
227
+ const configFile = hasConfigFile(projectPath, configFileMap[unsupported]);
228
+ if (configFile) {
229
+ configFiles.push(configFile);
230
+ }
231
+ }
232
+
233
+ // Collect detected dependencies
234
+ if (pkg) {
235
+ if (unsupported === "astro" && hasDep(pkg, "astro")) detectedDeps.push("astro");
236
+ if (unsupported === "nuxt" && hasDep(pkg, "nuxt")) detectedDeps.push("nuxt");
237
+ if (unsupported === "react-router" && hasDep(pkg, "@react-router/dev"))
238
+ detectedDeps.push("@react-router/dev");
239
+ if (
240
+ unsupported === "tanstack-start" &&
241
+ (hasDep(pkg, "@tanstack/react-start") || hasDep(pkg, "@tanstack/start"))
242
+ ) {
243
+ detectedDeps.push(
244
+ hasDep(pkg, "@tanstack/react-start") ? "@tanstack/react-start" : "@tanstack/start",
245
+ );
246
+ }
247
+ if (unsupported === "remix") {
248
+ if (hasDep(pkg, "@remix-run/node")) detectedDeps.push("@remix-run/node");
249
+ if (hasDep(pkg, "@remix-run/react")) detectedDeps.push("@remix-run/react");
250
+ }
251
+ }
252
+
253
+ return {
254
+ type: "unknown",
255
+ unsupportedFramework: unsupported,
256
+ error: FRAMEWORK_SETUP_MESSAGES[unsupported],
257
+ detectedDeps,
258
+ configFiles,
259
+ };
260
+ }
261
+
262
+ if (!pkg) {
263
+ return {
264
+ type: "unknown",
265
+ error: "No package.json found",
266
+ };
267
+ }
268
+
269
+ // SvelteKit detection
270
+ const svelteConfig = hasConfigFile(projectPath, "svelte.config");
271
+ if (svelteConfig && hasDep(pkg, "@sveltejs/kit")) {
272
+ configFiles.push(svelteConfig);
273
+ detectedDeps.push("@sveltejs/kit");
274
+
275
+ if (!hasDep(pkg, "@sveltejs/adapter-cloudflare")) {
276
+ return {
277
+ type: "sveltekit",
278
+ configFile: svelteConfig,
279
+ error:
280
+ "Missing @sveltejs/adapter-cloudflare dependency. Install it: bun add -D @sveltejs/adapter-cloudflare",
281
+ detectedDeps,
282
+ configFiles,
283
+ };
284
+ }
285
+
286
+ detectedDeps.push("@sveltejs/adapter-cloudflare");
287
+ return {
288
+ type: "sveltekit",
289
+ configFile: svelteConfig,
290
+ detectedDeps,
291
+ configFiles,
292
+ };
293
+ }
294
+
295
+ // Vite detection
296
+ const viteConfig = hasConfigFile(projectPath, "vite.config");
297
+ if (viteConfig && hasDep(pkg, "vite")) {
298
+ configFiles.push(viteConfig);
299
+ detectedDeps.push("vite");
300
+ return {
301
+ type: "vite",
302
+ configFile: viteConfig,
303
+ detectedDeps,
304
+ configFiles,
305
+ };
306
+ }
307
+
308
+ // Hono detection
309
+ if (hasDep(pkg, "hono")) {
310
+ detectedDeps.push("hono");
311
+ const entryPoint = findHonoEntry(projectPath);
312
+ if (entryPoint) {
313
+ return {
314
+ type: "hono",
315
+ entryPoint,
316
+ detectedDeps,
317
+ configFiles,
318
+ };
319
+ }
320
+ return {
321
+ type: "hono",
322
+ error:
323
+ "Hono detected but no entry file found. Expected: src/index.ts, index.ts, src/server.ts, or similar.",
324
+ detectedDeps,
325
+ configFiles,
326
+ };
327
+ }
328
+
329
+ return {
330
+ type: "unknown",
331
+ error: "Could not detect project type. Supported: Vite, SvelteKit, Hono.",
332
+ detectedDeps,
333
+ configFiles,
334
+ };
335
+ }
336
+
337
+ function shouldExclude(relativePath: string): boolean {
338
+ for (const pattern of DEFAULT_EXCLUDES) {
339
+ const glob = new Glob(pattern);
340
+ if (glob.match(relativePath)) {
341
+ return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
347
+ export async function validateProject(projectPath: string): Promise<ValidationResult> {
348
+ if (!existsSync(join(projectPath, "package.json"))) {
349
+ return {
350
+ valid: false,
351
+ error: "No package.json found in project directory",
352
+ };
353
+ }
354
+
355
+ let fileCount = 0;
356
+ let totalSizeBytes = 0;
357
+
358
+ try {
359
+ const entries = await readdir(projectPath, {
360
+ recursive: true,
361
+ withFileTypes: true,
362
+ });
363
+
364
+ for (const entry of entries) {
365
+ if (!entry.isFile()) {
366
+ continue;
367
+ }
368
+
369
+ const parentDir = entry.parentPath ?? projectPath;
370
+ const absolutePath = join(parentDir, entry.name);
371
+ const relativePath = relative(projectPath, absolutePath);
372
+
373
+ if (shouldExclude(relativePath)) {
374
+ continue;
375
+ }
376
+
377
+ fileCount++;
378
+ if (fileCount > MAX_FILES) {
379
+ return {
380
+ valid: false,
381
+ error: `Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`,
382
+ fileCount,
383
+ };
384
+ }
385
+
386
+ const stats = await stat(absolutePath);
387
+ totalSizeBytes += stats.size;
388
+ }
389
+
390
+ const totalSizeKb = Math.round(totalSizeBytes / 1024);
391
+
392
+ if (totalSizeKb > MAX_SIZE_KB) {
393
+ return {
394
+ valid: false,
395
+ error: `Project size exceeds ${MAX_SIZE_KB / 1024}MB limit (${Math.round(totalSizeKb / 1024)}MB)`,
396
+ fileCount,
397
+ totalSizeKb,
398
+ };
399
+ }
400
+
401
+ return {
402
+ valid: true,
403
+ fileCount,
404
+ totalSizeKb,
405
+ };
406
+ } catch (err) {
407
+ return {
408
+ valid: false,
409
+ error: `Failed to scan project: ${err instanceof Error ? err.message : String(err)}`,
410
+ };
411
+ }
412
+ }
@@ -336,15 +336,14 @@ describe("project-link", () => {
336
336
  expect(template).toEqual({ type: "builtin", name: "miniapp" });
337
337
  });
338
338
 
339
- it("creates template.json for github template", async () => {
339
+ it("creates template.json for published template", async () => {
340
340
  await writeTemplateMetadata(testDir, {
341
- type: "github",
342
- name: "user/repo",
343
- ref: "main",
341
+ type: "published",
342
+ name: "alice/my-api",
344
343
  });
345
344
 
346
345
  const template = await readTemplateMetadata(testDir);
347
- expect(template).toEqual({ type: "github", name: "user/repo", ref: "main" });
346
+ expect(template).toEqual({ type: "published", name: "alice/my-api" });
348
347
  });
349
348
 
350
349
  it("creates .jack directory if not exists", async () => {
@@ -29,15 +29,15 @@ export interface LocalProjectLink {
29
29
  deploy_mode: DeployMode;
30
30
  linked_at: string;
31
31
  tags?: string[];
32
+ owner_username?: string;
32
33
  }
33
34
 
34
35
  /**
35
36
  * Template metadata stored in .jack/template.json
36
37
  */
37
38
  export interface TemplateMetadata {
38
- type: "builtin" | "github";
39
- name: string; // "miniapp", "api", or "user/repo" for github
40
- ref?: string; // git ref for github templates
39
+ type: "builtin" | "user" | "published";
40
+ name: string; // "miniapp", "api", or "username/slug" for published
41
41
  }
42
42
 
43
43
  const JACK_DIR = ".jack";
@@ -94,6 +94,7 @@ export async function linkProject(
94
94
  projectDir: string,
95
95
  projectId: string,
96
96
  deployMode: DeployMode,
97
+ ownerUsername?: string,
97
98
  ): Promise<void> {
98
99
  const jackDir = getJackDir(projectDir);
99
100
  const linkPath = getProjectLinkPath(projectDir);
@@ -108,6 +109,7 @@ export async function linkProject(
108
109
  project_id: projectId,
109
110
  deploy_mode: deployMode,
110
111
  linked_at: new Date().toISOString(),
112
+ owner_username: ownerUsername,
111
113
  };
112
114
 
113
115
  await writeFile(linkPath, JSON.stringify(link, null, 2));