@getjack/jack 0.1.5 → 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.
- package/package.json +2 -1
- package/src/commands/login.ts +124 -1
- package/src/commands/projects.ts +5 -1
- package/src/commands/publish.ts +50 -0
- package/src/commands/ship.ts +4 -3
- package/src/index.ts +7 -0
- package/src/lib/binding-validator.ts +9 -4
- package/src/lib/build-helper.ts +61 -39
- package/src/lib/config-generator.ts +107 -0
- package/src/lib/control-plane.ts +150 -0
- package/src/lib/managed-deploy.ts +10 -2
- package/src/lib/project-detection.ts +412 -0
- package/src/lib/project-link.test.ts +4 -5
- package/src/lib/project-link.ts +5 -3
- package/src/lib/project-list.ts +5 -1
- package/src/lib/project-operations.ts +318 -21
- package/src/lib/project-resolver.ts +5 -1
- package/src/lib/storage/file-filter.ts +5 -0
- package/src/lib/telemetry.ts +24 -4
- package/src/lib/zip-packager.ts +8 -0
- package/src/templates/index.ts +137 -7
- package/templates/nextjs/.jack.json +26 -26
- package/templates/nextjs/app/globals.css +4 -4
- package/templates/nextjs/app/layout.tsx +11 -11
- package/templates/nextjs/app/page.tsx +8 -6
- package/templates/nextjs/cloudflare-env.d.ts +1 -1
- package/templates/nextjs/next.config.ts +1 -1
- package/templates/nextjs/open-next.config.ts +1 -1
- package/templates/nextjs/package.json +22 -22
- package/templates/nextjs/tsconfig.json +26 -42
- package/templates/nextjs/wrangler.jsonc +15 -15
- package/src/lib/github.ts +0 -151
- package/templates/nextjs/app/isr-test/page.tsx +0 -22
package/src/lib/control-plane.ts
CHANGED
|
@@ -49,6 +49,33 @@ export interface SlugAvailabilityResponse {
|
|
|
49
49
|
error?: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export interface UsernameAvailabilityResponse {
|
|
53
|
+
available: boolean;
|
|
54
|
+
username: string;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SetUsernameResponse {
|
|
59
|
+
success: boolean;
|
|
60
|
+
username: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UserProfile {
|
|
64
|
+
id: string;
|
|
65
|
+
email: string;
|
|
66
|
+
first_name: string | null;
|
|
67
|
+
last_name: string | null;
|
|
68
|
+
username: string | null;
|
|
69
|
+
created_at: string;
|
|
70
|
+
updated_at: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PublishProjectResponse {
|
|
74
|
+
success: boolean;
|
|
75
|
+
published_as: string;
|
|
76
|
+
fork_command: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
export interface CreateDeploymentRequest {
|
|
53
80
|
source: string;
|
|
54
81
|
}
|
|
@@ -166,6 +193,7 @@ export interface ManagedProject {
|
|
|
166
193
|
created_at: string;
|
|
167
194
|
updated_at: string;
|
|
168
195
|
tags?: string; // JSON string array from DB, e.g., '["backend", "api"]'
|
|
196
|
+
owner_username?: string | null;
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
/**
|
|
@@ -341,3 +369,125 @@ export async function fetchProjectTags(projectId: string): Promise<string[]> {
|
|
|
341
369
|
return [];
|
|
342
370
|
}
|
|
343
371
|
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Check if a username is available on jack cloud.
|
|
375
|
+
* Does not require authentication.
|
|
376
|
+
*/
|
|
377
|
+
export async function checkUsernameAvailable(
|
|
378
|
+
username: string,
|
|
379
|
+
): Promise<UsernameAvailabilityResponse> {
|
|
380
|
+
const response = await fetch(
|
|
381
|
+
`${getControlApiUrl()}/v1/usernames/${encodeURIComponent(username)}/available`,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
throw new Error(`Failed to check username availability: ${response.status}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return response.json() as Promise<UsernameAvailabilityResponse>;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Set the current user's username.
|
|
393
|
+
* Can only be called once per user.
|
|
394
|
+
*/
|
|
395
|
+
export async function setUsername(username: string): Promise<SetUsernameResponse> {
|
|
396
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
397
|
+
|
|
398
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/me/username`, {
|
|
399
|
+
method: "PUT",
|
|
400
|
+
headers: { "Content-Type": "application/json" },
|
|
401
|
+
body: JSON.stringify({ username }),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (response.status === 409) {
|
|
405
|
+
const err = (await response.json().catch(() => ({ message: "Username taken" }))) as {
|
|
406
|
+
message?: string;
|
|
407
|
+
};
|
|
408
|
+
throw new Error(err.message || "Username is already taken");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!response.ok) {
|
|
412
|
+
const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
|
|
413
|
+
message?: string;
|
|
414
|
+
};
|
|
415
|
+
throw new Error(err.message || `Failed to set username: ${response.status}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return response.json() as Promise<SetUsernameResponse>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get the current user's profile including username.
|
|
423
|
+
*/
|
|
424
|
+
export async function getCurrentUserProfile(): Promise<UserProfile | null> {
|
|
425
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/me`);
|
|
429
|
+
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const data = (await response.json()) as { user: UserProfile };
|
|
435
|
+
return data.user;
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export interface SourceSnapshotResponse {
|
|
442
|
+
success: boolean;
|
|
443
|
+
source_key: string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Upload a source snapshot for a project.
|
|
448
|
+
* Used to enable project forking.
|
|
449
|
+
*/
|
|
450
|
+
export async function uploadSourceSnapshot(
|
|
451
|
+
projectId: string,
|
|
452
|
+
sourceZipPath: string,
|
|
453
|
+
): Promise<SourceSnapshotResponse> {
|
|
454
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
455
|
+
|
|
456
|
+
const formData = new FormData();
|
|
457
|
+
const sourceFile = Bun.file(sourceZipPath);
|
|
458
|
+
formData.append("source", sourceFile);
|
|
459
|
+
|
|
460
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/source`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
body: formData,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
const error = (await response.json().catch(() => ({ message: "Upload failed" }))) as {
|
|
467
|
+
message?: string;
|
|
468
|
+
};
|
|
469
|
+
throw new Error(error.message || `Source upload failed: ${response.status}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return response.json() as Promise<SourceSnapshotResponse>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Publish a project to make it forkable by others.
|
|
477
|
+
*/
|
|
478
|
+
export async function publishProject(projectId: string): Promise<PublishProjectResponse> {
|
|
479
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
480
|
+
|
|
481
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/publish`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (!response.ok) {
|
|
486
|
+
const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
|
|
487
|
+
message?: string;
|
|
488
|
+
};
|
|
489
|
+
throw new Error(err.message || `Failed to publish project: ${response.status}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return response.json() as Promise<PublishProjectResponse>;
|
|
493
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { validateBindings } from "./binding-validator.ts";
|
|
8
8
|
import { buildProject, parseWranglerConfig } from "./build-helper.ts";
|
|
9
|
-
import { createManagedProject, syncProjectTags } from "./control-plane.ts";
|
|
9
|
+
import { createManagedProject, syncProjectTags, uploadSourceSnapshot } from "./control-plane.ts";
|
|
10
|
+
import { debug } from "./debug.ts";
|
|
10
11
|
import { uploadDeployment } from "./deploy-upload.ts";
|
|
11
12
|
import { JackError, JackErrorCode } from "./errors.ts";
|
|
12
13
|
import type { OperationReporter } from "./project-operations.ts";
|
|
@@ -48,7 +49,7 @@ export async function createManagedProjectRemote(
|
|
|
48
49
|
usePrebuilt: options?.usePrebuilt ?? true,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
const runjackUrl = `https://${result.project.slug}.runjack.xyz`;
|
|
52
|
+
const runjackUrl = result.url || `https://${result.project.slug}.runjack.xyz`;
|
|
52
53
|
|
|
53
54
|
reporter?.stop();
|
|
54
55
|
reporter?.success("Created managed project");
|
|
@@ -148,6 +149,13 @@ export async function deployCodeToManagedProject(
|
|
|
148
149
|
})
|
|
149
150
|
.catch(() => {});
|
|
150
151
|
|
|
152
|
+
// Upload source snapshot for forking (non-fatal, but must await before cleanup)
|
|
153
|
+
try {
|
|
154
|
+
await uploadSourceSnapshot(projectId, pkg.sourceZipPath);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
debug("Source snapshot upload failed:", err instanceof Error ? err.message : String(err));
|
|
157
|
+
}
|
|
158
|
+
|
|
151
159
|
return {
|
|
152
160
|
deploymentId: result.id,
|
|
153
161
|
status: result.status,
|
|
@@ -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
|
|
339
|
+
it("creates template.json for published template", async () => {
|
|
340
340
|
await writeTemplateMetadata(testDir, {
|
|
341
|
-
type: "
|
|
342
|
-
name: "
|
|
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: "
|
|
346
|
+
expect(template).toEqual({ type: "published", name: "alice/my-api" });
|
|
348
347
|
});
|
|
349
348
|
|
|
350
349
|
it("creates .jack directory if not exists", async () => {
|
package/src/lib/project-link.ts
CHANGED
|
@@ -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" | "
|
|
39
|
-
name: string; // "miniapp", "api", or "
|
|
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));
|
package/src/lib/project-list.ts
CHANGED
|
@@ -307,7 +307,11 @@ export function formatProjectLine(item: ProjectListItem, options: FormatLineOpti
|
|
|
307
307
|
let url = "";
|
|
308
308
|
if (showUrl && item.url) {
|
|
309
309
|
url = item.url.replace("https://", "");
|
|
310
|
-
} else if (
|
|
310
|
+
} else if (
|
|
311
|
+
showUrl &&
|
|
312
|
+
(item.status === "error" || item.status === "auth-expired") &&
|
|
313
|
+
item.errorMessage
|
|
314
|
+
) {
|
|
311
315
|
url = `${colors.dim}${item.errorMessage}${colors.reset}`;
|
|
312
316
|
}
|
|
313
317
|
|