@getjack/jack 0.1.6 → 0.1.8
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 +6 -2
- package/src/commands/down.ts +20 -3
- package/src/commands/mcp.ts +17 -1
- package/src/commands/publish.ts +50 -0
- package/src/commands/ship.ts +4 -3
- package/src/index.ts +7 -0
- package/src/lib/agent-files.ts +0 -2
- package/src/lib/binding-validator.ts +9 -4
- package/src/lib/build-helper.ts +67 -45
- package/src/lib/config-generator.ts +120 -0
- package/src/lib/config.ts +2 -1
- package/src/lib/control-plane.ts +61 -0
- package/src/lib/managed-deploy.ts +10 -2
- package/src/lib/mcp-config.ts +2 -1
- package/src/lib/output.ts +21 -1
- package/src/lib/project-detection.ts +431 -0
- package/src/lib/project-link.test.ts +4 -5
- package/src/lib/project-link.ts +5 -3
- package/src/lib/project-operations.ts +334 -35
- package/src/lib/project-resolver.ts +9 -2
- package/src/lib/secrets.ts +1 -2
- package/src/lib/storage/file-filter.ts +5 -0
- package/src/lib/telemetry-config.ts +3 -3
- package/src/lib/telemetry.ts +4 -0
- package/src/lib/zip-packager.ts +8 -0
- package/src/mcp/test-utils.ts +112 -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/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getjack/jack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"jack": "./src/index.ts"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"templates"
|
|
13
|
+
],
|
|
11
14
|
"engines": {
|
|
12
15
|
"bun": ">=1.0.0"
|
|
13
16
|
},
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
"@inquirer/prompts": "^7.0.0",
|
|
45
48
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
46
49
|
"archiver": "^7.0.1",
|
|
50
|
+
"fflate": "^0.8.2",
|
|
47
51
|
"human-id": "^4.1.3",
|
|
48
52
|
"meow": "^14.0.0",
|
|
49
53
|
"yocto-spinner": "^1.0.0",
|
package/src/commands/down.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface DownFlags {
|
|
|
62
62
|
export default async function down(projectName?: string, flags: DownFlags = {}): Promise<void> {
|
|
63
63
|
try {
|
|
64
64
|
// Get project name
|
|
65
|
+
const hasExplicitName = Boolean(projectName);
|
|
65
66
|
let name = projectName;
|
|
66
67
|
if (!name) {
|
|
67
68
|
try {
|
|
@@ -74,10 +75,12 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Resolve project from all sources (local link + control plane)
|
|
77
|
-
const resolved = await resolveProject(name
|
|
78
|
+
const resolved = await resolveProject(name, {
|
|
79
|
+
preferLocalLink: !hasExplicitName,
|
|
80
|
+
});
|
|
78
81
|
|
|
79
|
-
// Read local project link
|
|
80
|
-
const link = await readProjectLink(process.cwd());
|
|
82
|
+
// Read local project link (only when no explicit name provided)
|
|
83
|
+
const link = hasExplicitName ? null : await readProjectLink(process.cwd());
|
|
81
84
|
|
|
82
85
|
// Check if found only on control plane (orphaned managed project)
|
|
83
86
|
if (resolved?.sources.controlPlane && !resolved.sources.filesystem) {
|
|
@@ -85,6 +88,20 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
|
|
|
85
88
|
info(`Found "${name}" on jack cloud, linking locally...`);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
|
|
92
|
+
// Guard against mismatched resolutions when an explicit name is provided
|
|
93
|
+
if (hasExplicitName && resolved) {
|
|
94
|
+
const matches =
|
|
95
|
+
name === resolved.slug ||
|
|
96
|
+
name === resolved.name ||
|
|
97
|
+
name === resolved.remote?.projectId;
|
|
98
|
+
if (!matches) {
|
|
99
|
+
error(`Refusing to undeploy '${name}' because it resolves to '${resolved.slug}'.`);
|
|
100
|
+
info("Use the exact slug/name shown by 'jack info' and try again.");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
if (!resolved && !link) {
|
|
89
106
|
// Not found anywhere
|
|
90
107
|
warn(`Project '${name}' not found`);
|
package/src/commands/mcp.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { rm, mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
2
6
|
import { error, info, success } from "../lib/output.ts";
|
|
3
7
|
import { startMcpServer } from "../mcp/server.ts";
|
|
4
8
|
|
|
9
|
+
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
10
|
+
|
|
5
11
|
interface McpOptions {
|
|
6
12
|
project?: string;
|
|
7
13
|
debug?: boolean;
|
|
@@ -32,10 +38,19 @@ export default async function mcp(subcommand?: string, options: McpOptions = {})
|
|
|
32
38
|
* Test MCP server by spawning it and sending test requests
|
|
33
39
|
*/
|
|
34
40
|
async function testMcpServer(): Promise<void> {
|
|
41
|
+
const configDir = await mkdtemp(join(tmpdir(), "jack-config-"));
|
|
42
|
+
|
|
35
43
|
info("Testing MCP server...\n");
|
|
36
44
|
|
|
37
|
-
const proc = spawn("
|
|
45
|
+
const proc = spawn("bun", ["run", "src/index.ts", "mcp", "serve"], {
|
|
38
46
|
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
+
cwd: cliRoot,
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
CI: "1",
|
|
51
|
+
JACK_TELEMETRY_DISABLED: "1",
|
|
52
|
+
JACK_CONFIG_DIR: configDir,
|
|
53
|
+
},
|
|
39
54
|
});
|
|
40
55
|
|
|
41
56
|
const results: { test: string; passed: boolean; error?: string }[] = [];
|
|
@@ -126,6 +141,7 @@ async function testMcpServer(): Promise<void> {
|
|
|
126
141
|
error(` ✗ Error: ${errorMsg}`);
|
|
127
142
|
} finally {
|
|
128
143
|
proc.kill();
|
|
144
|
+
await rm(configDir, { recursive: true, force: true });
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
// Summary
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getCurrentUserProfile, publishProject } from "../lib/control-plane.ts";
|
|
2
|
+
import { output, spinner } from "../lib/output.ts";
|
|
3
|
+
import { readProjectLink } from "../lib/project-link.ts";
|
|
4
|
+
|
|
5
|
+
export default async function publish(): Promise<void> {
|
|
6
|
+
// Check we're in a project directory
|
|
7
|
+
const link = await readProjectLink(process.cwd());
|
|
8
|
+
if (!link) {
|
|
9
|
+
output.error("Not in a jack project directory");
|
|
10
|
+
output.info("Run this command from a directory with a .jack folder");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (link.deploy_mode !== "managed") {
|
|
15
|
+
output.error("Only managed projects can be published");
|
|
16
|
+
output.info("Projects deployed with BYOC (bring your own cloud) cannot be published");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!link.project_id) {
|
|
21
|
+
output.error("Project not linked to jack cloud");
|
|
22
|
+
output.info("Run: jack ship (to deploy and link the project)");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check user has username
|
|
27
|
+
const profile = await getCurrentUserProfile();
|
|
28
|
+
if (!profile?.username) {
|
|
29
|
+
output.error("You need a username to publish projects");
|
|
30
|
+
output.info("Run: jack login (to set up your username)");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spin = spinner("Publishing project...");
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await publishProject(link.project_id);
|
|
38
|
+
spin.stop();
|
|
39
|
+
output.success(`Published as ${result.published_as}`);
|
|
40
|
+
|
|
41
|
+
console.error("");
|
|
42
|
+
output.info("Others can now fork your project:");
|
|
43
|
+
output.info(` ${result.fork_command}`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
spin.stop();
|
|
46
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
47
|
+
output.error(`Publish failed: ${message}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/commands/ship.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { output, spinner } from "../lib/output.ts";
|
|
|
3
3
|
import { deployProject } from "../lib/project-operations.ts";
|
|
4
4
|
|
|
5
5
|
export default async function ship(
|
|
6
|
-
options: { managed?: boolean; byo?: boolean } = {},
|
|
6
|
+
options: { managed?: boolean; byo?: boolean; dryRun?: boolean } = {},
|
|
7
7
|
): Promise<void> {
|
|
8
8
|
const isCi = process.env.CI === "true" || process.env.CI === "1";
|
|
9
9
|
try {
|
|
@@ -20,10 +20,11 @@ export default async function ship(
|
|
|
20
20
|
box: output.box,
|
|
21
21
|
},
|
|
22
22
|
interactive: !isCi,
|
|
23
|
-
includeSecrets:
|
|
24
|
-
includeSync:
|
|
23
|
+
includeSecrets: !options.dryRun,
|
|
24
|
+
includeSync: !options.dryRun,
|
|
25
25
|
managed: options.managed,
|
|
26
26
|
byo: options.byo,
|
|
27
|
+
dryRun: options.dryRun,
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
if (!result.workerUrl && result.deployOutput) {
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ const cli = meow(
|
|
|
25
25
|
down [name] Undeploy from cloud
|
|
26
26
|
ls List all projects
|
|
27
27
|
info [name] Show project details
|
|
28
|
+
publish Make your project forkable by others
|
|
28
29
|
|
|
29
30
|
Cloud & Sync
|
|
30
31
|
clone <project> Pull from cloud backup
|
|
@@ -206,6 +207,7 @@ try {
|
|
|
206
207
|
)({
|
|
207
208
|
managed: cli.flags.managed,
|
|
208
209
|
byo: cli.flags.byo,
|
|
210
|
+
dryRun: cli.flags.dryRun,
|
|
209
211
|
});
|
|
210
212
|
break;
|
|
211
213
|
}
|
|
@@ -264,6 +266,11 @@ try {
|
|
|
264
266
|
await withTelemetry("down", down)(args[0], { force: cli.flags.force });
|
|
265
267
|
break;
|
|
266
268
|
}
|
|
269
|
+
case "publish": {
|
|
270
|
+
const { default: publish } = await import("./commands/publish.ts");
|
|
271
|
+
await withTelemetry("publish", publish)();
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
267
274
|
case "open": {
|
|
268
275
|
const { default: open } = await import("./commands/open.ts");
|
|
269
276
|
await withTelemetry("open", open)(args[0], { dash: cli.flags.dash, logs: cli.flags.logs });
|
package/src/lib/agent-files.ts
CHANGED
|
@@ -21,7 +21,6 @@ This project is deployed to Cloudflare Workers using jack:
|
|
|
21
21
|
\`\`\`bash
|
|
22
22
|
jack ship # Deploy to Cloudflare Workers
|
|
23
23
|
jack logs # Stream production logs
|
|
24
|
-
jack dev # Start local development server
|
|
25
24
|
\`\`\`
|
|
26
25
|
|
|
27
26
|
All deployment is handled by jack. Never run \`wrangler\` commands directly.
|
|
@@ -42,7 +41,6 @@ See [AGENTS.md](./AGENTS.md) for complete project context and deployment instruc
|
|
|
42
41
|
|
|
43
42
|
- **Deploy**: \`jack ship\` - Deploy to Cloudflare Workers
|
|
44
43
|
- **Logs**: \`jack logs\` - Stream production logs
|
|
45
|
-
- **Dev**: \`jack dev\` - Start local development server
|
|
46
44
|
|
|
47
45
|
## Important
|
|
48
46
|
|
|
@@ -12,14 +12,20 @@ import type { WranglerConfig } from "./build-helper.ts";
|
|
|
12
12
|
/**
|
|
13
13
|
* Bindings supported by jack cloud managed deployments.
|
|
14
14
|
*/
|
|
15
|
-
export const SUPPORTED_BINDINGS = [
|
|
15
|
+
export const SUPPORTED_BINDINGS = [
|
|
16
|
+
"d1_databases",
|
|
17
|
+
"ai",
|
|
18
|
+
"assets",
|
|
19
|
+
"vars",
|
|
20
|
+
"r2_buckets",
|
|
21
|
+
"kv_namespaces",
|
|
22
|
+
] as const;
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Bindings not yet supported by jack cloud.
|
|
19
26
|
* These will cause validation errors if present in wrangler config.
|
|
20
27
|
*/
|
|
21
28
|
export const UNSUPPORTED_BINDINGS = [
|
|
22
|
-
"kv_namespaces",
|
|
23
29
|
"durable_objects",
|
|
24
30
|
"queues",
|
|
25
31
|
"services",
|
|
@@ -33,7 +39,6 @@ export const UNSUPPORTED_BINDINGS = [
|
|
|
33
39
|
* Human-readable names for unsupported bindings.
|
|
34
40
|
*/
|
|
35
41
|
const BINDING_DISPLAY_NAMES: Record<string, string> = {
|
|
36
|
-
kv_namespaces: "KV Namespaces",
|
|
37
42
|
durable_objects: "Durable Objects",
|
|
38
43
|
queues: "Queues",
|
|
39
44
|
services: "Service Bindings",
|
|
@@ -67,7 +72,7 @@ export function validateBindings(
|
|
|
67
72
|
if (value !== undefined && value !== null) {
|
|
68
73
|
const displayName = BINDING_DISPLAY_NAMES[binding] || binding;
|
|
69
74
|
errors.push(
|
|
70
|
-
`✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
|
|
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.`,
|
|
71
76
|
);
|
|
72
77
|
}
|
|
73
78
|
}
|
package/src/lib/build-helper.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
@@ -40,8 +40,11 @@ export interface WranglerConfig {
|
|
|
40
40
|
binding: string;
|
|
41
41
|
bucket_name: string;
|
|
42
42
|
}>;
|
|
43
|
+
kv_namespaces?: Array<{
|
|
44
|
+
binding: string;
|
|
45
|
+
id?: string; // Optional - wrangler auto-provisions if missing
|
|
46
|
+
}>;
|
|
43
47
|
// Unsupported bindings (for validation)
|
|
44
|
-
kv_namespaces?: unknown;
|
|
45
48
|
durable_objects?: unknown;
|
|
46
49
|
queues?: unknown;
|
|
47
50
|
services?: unknown;
|
|
@@ -103,9 +106,20 @@ export async function needsOpenNextBuild(projectPath: string): Promise<boolean>
|
|
|
103
106
|
* @throws JackError if build fails
|
|
104
107
|
*/
|
|
105
108
|
export async function runViteBuild(projectPath: string): Promise<void> {
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
+
// Use local vite if installed to avoid module resolution issues
|
|
110
|
+
// bunx vite installs to temp dir, but vite.config.js may require('vite') from node_modules
|
|
111
|
+
// Don't use project's build script - it might do more than just vite build (e.g., Tauri)
|
|
112
|
+
let buildCommand: string[];
|
|
113
|
+
|
|
114
|
+
if (existsSync(join(projectPath, "node_modules", ".bin", "vite"))) {
|
|
115
|
+
// Local vite installed - use it directly
|
|
116
|
+
buildCommand = ["bun", "run", "vite", "build"];
|
|
117
|
+
} else {
|
|
118
|
+
// Fallback to bunx
|
|
119
|
+
buildCommand = ["bunx", "vite", "build"];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const buildResult = await $`${buildCommand}`.cwd(projectPath).nothrow().quiet();
|
|
109
123
|
|
|
110
124
|
if (buildResult.exitCode !== 0) {
|
|
111
125
|
throw new JackError(
|
|
@@ -158,19 +172,19 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
158
172
|
// Check if OpenNext build is needed (Next.js + Cloudflare)
|
|
159
173
|
const hasOpenNext = await needsOpenNextBuild(projectPath);
|
|
160
174
|
if (hasOpenNext) {
|
|
161
|
-
reporter?.start("Building...");
|
|
175
|
+
reporter?.start("Building assets...");
|
|
162
176
|
await runOpenNextBuild(projectPath);
|
|
163
177
|
reporter?.stop();
|
|
164
|
-
reporter?.success("Built");
|
|
178
|
+
reporter?.success("Built assets");
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
// Check if Vite build is needed and run it (skip if OpenNext already built)
|
|
168
182
|
const hasVite = await needsViteBuild(projectPath);
|
|
169
183
|
if (hasVite && !hasOpenNext) {
|
|
170
|
-
reporter?.start("Building...");
|
|
184
|
+
reporter?.start("Building assets...");
|
|
171
185
|
await runViteBuild(projectPath);
|
|
172
186
|
reporter?.stop();
|
|
173
|
-
reporter?.success("Built");
|
|
187
|
+
reporter?.success("Built assets");
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
// Create unique temp directory for build output
|
|
@@ -179,7 +193,7 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
179
193
|
await mkdir(outDir, { recursive: true });
|
|
180
194
|
|
|
181
195
|
// Run wrangler dry-run to build without deploying
|
|
182
|
-
reporter?.start("
|
|
196
|
+
reporter?.start("Bundling runtime...");
|
|
183
197
|
|
|
184
198
|
const dryRunResult = await $`wrangler deploy --dry-run --outdir=${outDir}`
|
|
185
199
|
.cwd(projectPath)
|
|
@@ -201,7 +215,7 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
reporter?.stop();
|
|
204
|
-
reporter?.success("
|
|
218
|
+
reporter?.success("Bundled runtime");
|
|
205
219
|
|
|
206
220
|
const entrypoint = await resolveEntrypoint(outDir, config.main);
|
|
207
221
|
|
|
@@ -258,42 +272,50 @@ async function resolveEntrypoint(outDir: string, main?: string): Promise<string>
|
|
|
258
272
|
}
|
|
259
273
|
|
|
260
274
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* @param projectPath - Absolute path to project directory
|
|
264
|
-
* @returns Array of bucket names that were created or already existed
|
|
275
|
+
* Gets the installed wrangler version.
|
|
276
|
+
* @returns Version string (e.g., "4.55.0")
|
|
265
277
|
*/
|
|
266
|
-
export async function
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
export async function getWranglerVersion(): Promise<string> {
|
|
279
|
+
const result = await $`wrangler --version`.nothrow().quiet();
|
|
280
|
+
if (result.exitCode !== 0) {
|
|
281
|
+
throw new JackError(
|
|
282
|
+
JackErrorCode.VALIDATION_ERROR,
|
|
283
|
+
"wrangler not found",
|
|
284
|
+
"Install wrangler: npm install -g wrangler",
|
|
285
|
+
);
|
|
271
286
|
}
|
|
287
|
+
// Parse "wrangler 4.55.0" -> "4.55.0"
|
|
288
|
+
const match = result.stdout.toString().match(/(\d+\.\d+\.\d+)/);
|
|
289
|
+
return match?.[1] ?? "0.0.0";
|
|
290
|
+
}
|
|
272
291
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
for (const bucket of config.r2_buckets) {
|
|
276
|
-
const bucketName = bucket.bucket_name;
|
|
277
|
-
|
|
278
|
-
// Try to create the bucket (wrangler handles "already exists" gracefully)
|
|
279
|
-
const result = await $`wrangler r2 bucket create ${bucketName}`
|
|
280
|
-
.cwd(projectPath)
|
|
281
|
-
.nothrow()
|
|
282
|
-
.quiet();
|
|
283
|
-
|
|
284
|
-
// Exit code 0 = created, non-zero with "already exists" = fine
|
|
285
|
-
const stderr = result.stderr.toString();
|
|
286
|
-
if (result.exitCode === 0 || stderr.includes("already exists")) {
|
|
287
|
-
results.push(bucketName);
|
|
288
|
-
} else {
|
|
289
|
-
throw new JackError(
|
|
290
|
-
JackErrorCode.RESOURCE_ERROR,
|
|
291
|
-
`Failed to create R2 bucket: ${bucketName}`,
|
|
292
|
-
"Check your Cloudflare account has R2 enabled",
|
|
293
|
-
{ stderr },
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
292
|
+
const MIN_WRANGLER_VERSION = "4.45.0";
|
|
297
293
|
|
|
298
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Checks if wrangler version meets minimum requirement for auto-provisioning.
|
|
296
|
+
* @throws JackError if version is too old
|
|
297
|
+
*/
|
|
298
|
+
export function checkWranglerVersion(version: string): void {
|
|
299
|
+
const parts = version.split(".").map(Number);
|
|
300
|
+
const minParts = MIN_WRANGLER_VERSION.split(".").map(Number);
|
|
301
|
+
|
|
302
|
+
const major = parts[0] ?? 0;
|
|
303
|
+
const minor = parts[1] ?? 0;
|
|
304
|
+
const patch = parts[2] ?? 0;
|
|
305
|
+
const minMajor = minParts[0] ?? 0;
|
|
306
|
+
const minMinor = minParts[1] ?? 0;
|
|
307
|
+
const minPatch = minParts[2] ?? 0;
|
|
308
|
+
|
|
309
|
+
const isValid =
|
|
310
|
+
major > minMajor ||
|
|
311
|
+
(major === minMajor && minor > minMinor) ||
|
|
312
|
+
(major === minMajor && minor === minMinor && patch >= minPatch);
|
|
313
|
+
|
|
314
|
+
if (!isValid) {
|
|
315
|
+
throw new JackError(
|
|
316
|
+
JackErrorCode.VALIDATION_ERROR,
|
|
317
|
+
`wrangler ${MIN_WRANGLER_VERSION}+ required (found ${version})`,
|
|
318
|
+
"Run: npm install -g wrangler@latest",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
299
321
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ProjectType = "nextjs" | "vite" | "hono" | "sveltekit" | "worker" | "unknown";
|
|
5
|
+
|
|
6
|
+
export interface WranglerConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
main?: string;
|
|
9
|
+
compatibility_date: string;
|
|
10
|
+
assets?: {
|
|
11
|
+
directory: string;
|
|
12
|
+
binding?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COMPATIBILITY_DATE = "2024-12-01";
|
|
17
|
+
|
|
18
|
+
export function generateWranglerConfig(
|
|
19
|
+
projectType: ProjectType,
|
|
20
|
+
projectName: string,
|
|
21
|
+
entryPoint?: string,
|
|
22
|
+
): WranglerConfig {
|
|
23
|
+
switch (projectType) {
|
|
24
|
+
case "nextjs":
|
|
25
|
+
return {
|
|
26
|
+
name: projectName,
|
|
27
|
+
main: ".open-next/worker.js",
|
|
28
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
29
|
+
assets: {
|
|
30
|
+
directory: ".open-next/assets",
|
|
31
|
+
binding: "ASSETS",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
case "vite":
|
|
36
|
+
// Check if this is a Vite + Worker hybrid (has entryPoint)
|
|
37
|
+
if (entryPoint) {
|
|
38
|
+
// Hybrid mode: Vite frontend + custom Worker backend
|
|
39
|
+
return {
|
|
40
|
+
name: projectName,
|
|
41
|
+
main: entryPoint,
|
|
42
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
43
|
+
assets: {
|
|
44
|
+
directory: "./dist",
|
|
45
|
+
binding: "ASSETS",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Pure Vite SPAs use assets-only mode (no worker entry)
|
|
50
|
+
// Cloudflare auto-generates a worker that serves static files
|
|
51
|
+
return {
|
|
52
|
+
name: projectName,
|
|
53
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
54
|
+
assets: {
|
|
55
|
+
directory: "./dist",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
case "hono":
|
|
60
|
+
return {
|
|
61
|
+
name: projectName,
|
|
62
|
+
main: entryPoint || "src/index.ts",
|
|
63
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
case "sveltekit":
|
|
67
|
+
return {
|
|
68
|
+
name: projectName,
|
|
69
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
70
|
+
assets: {
|
|
71
|
+
directory: "./.svelte-kit/cloudflare",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
return {
|
|
77
|
+
name: projectName,
|
|
78
|
+
main: entryPoint || "src/index.ts",
|
|
79
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function writeWranglerConfig(projectPath: string, config: WranglerConfig): void {
|
|
85
|
+
const header = "// wrangler.jsonc (auto-generated by jack)\n";
|
|
86
|
+
const json = JSON.stringify(config, null, 2);
|
|
87
|
+
const content = `${header}${json}\n`;
|
|
88
|
+
const filePath = path.join(projectPath, "wrangler.jsonc");
|
|
89
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getDefaultProjectName(projectPath: string): string {
|
|
93
|
+
const packageJsonPath = path.join(projectPath, "package.json");
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
96
|
+
try {
|
|
97
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
98
|
+
if (packageJson.name && typeof packageJson.name === "string") {
|
|
99
|
+
const slugified = slugify(packageJson.name);
|
|
100
|
+
if (slugified) {
|
|
101
|
+
return slugified;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Fall through to folder name
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const folderName = path.basename(path.resolve(projectPath));
|
|
110
|
+
return slugify(folderName) || "my-project";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function slugify(name: string): string {
|
|
114
|
+
return name
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/[\s_]+/g, "-")
|
|
117
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
118
|
+
.replace(/-+/g, "-")
|
|
119
|
+
.replace(/^-+|-+$/g, "");
|
|
120
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -40,7 +40,8 @@ export interface JackConfig {
|
|
|
40
40
|
sync?: SyncConfig;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "jack");
|
|
44
|
+
export const CONFIG_DIR = process.env.JACK_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
|
|
44
45
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
45
46
|
|
|
46
47
|
/**
|
package/src/lib/control-plane.ts
CHANGED
|
@@ -70,6 +70,12 @@ export interface UserProfile {
|
|
|
70
70
|
updated_at: string;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export interface PublishProjectResponse {
|
|
74
|
+
success: boolean;
|
|
75
|
+
published_as: string;
|
|
76
|
+
fork_command: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
73
79
|
export interface CreateDeploymentRequest {
|
|
74
80
|
source: string;
|
|
75
81
|
}
|
|
@@ -187,6 +193,7 @@ export interface ManagedProject {
|
|
|
187
193
|
created_at: string;
|
|
188
194
|
updated_at: string;
|
|
189
195
|
tags?: string; // JSON string array from DB, e.g., '["backend", "api"]'
|
|
196
|
+
owner_username?: string | null;
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
/**
|
|
@@ -430,3 +437,57 @@ export async function getCurrentUserProfile(): Promise<UserProfile | null> {
|
|
|
430
437
|
return null;
|
|
431
438
|
}
|
|
432
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,
|