@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.
Files changed (38) hide show
  1. package/package.json +6 -2
  2. package/src/commands/down.ts +20 -3
  3. package/src/commands/mcp.ts +17 -1
  4. package/src/commands/publish.ts +50 -0
  5. package/src/commands/ship.ts +4 -3
  6. package/src/index.ts +7 -0
  7. package/src/lib/agent-files.ts +0 -2
  8. package/src/lib/binding-validator.ts +9 -4
  9. package/src/lib/build-helper.ts +67 -45
  10. package/src/lib/config-generator.ts +120 -0
  11. package/src/lib/config.ts +2 -1
  12. package/src/lib/control-plane.ts +61 -0
  13. package/src/lib/managed-deploy.ts +10 -2
  14. package/src/lib/mcp-config.ts +2 -1
  15. package/src/lib/output.ts +21 -1
  16. package/src/lib/project-detection.ts +431 -0
  17. package/src/lib/project-link.test.ts +4 -5
  18. package/src/lib/project-link.ts +5 -3
  19. package/src/lib/project-operations.ts +334 -35
  20. package/src/lib/project-resolver.ts +9 -2
  21. package/src/lib/secrets.ts +1 -2
  22. package/src/lib/storage/file-filter.ts +5 -0
  23. package/src/lib/telemetry-config.ts +3 -3
  24. package/src/lib/telemetry.ts +4 -0
  25. package/src/lib/zip-packager.ts +8 -0
  26. package/src/mcp/test-utils.ts +112 -0
  27. package/src/templates/index.ts +137 -7
  28. package/templates/nextjs/.jack.json +26 -26
  29. package/templates/nextjs/app/globals.css +4 -4
  30. package/templates/nextjs/app/layout.tsx +11 -11
  31. package/templates/nextjs/app/page.tsx +8 -6
  32. package/templates/nextjs/cloudflare-env.d.ts +1 -1
  33. package/templates/nextjs/next.config.ts +1 -1
  34. package/templates/nextjs/open-next.config.ts +1 -1
  35. package/templates/nextjs/package.json +22 -22
  36. package/templates/nextjs/tsconfig.json +26 -42
  37. package/templates/nextjs/wrangler.jsonc +15 -15
  38. 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.6",
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": ["src", "templates"],
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",
@@ -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`);
@@ -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("./src/index.ts", ["mcp", "serve"], {
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
+ }
@@ -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: true,
24
- includeSync: true,
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 });
@@ -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 = ["d1_databases", "ai", "assets", "vars", "r2_buckets"] as const;
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
  }
@@ -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
- // Jack controls the build for managed projects (omakase)
107
- // Users wanting tsc can run `bun run build` manually before shipping
108
- const buildResult = await $`bunx vite build`.cwd(projectPath).nothrow().quiet();
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("Building worker...");
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("Built worker");
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
- * Ensures R2 buckets exist for BYO deploy.
262
- * Creates buckets via wrangler if they don't exist.
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 ensureR2Buckets(projectPath: string): Promise<string[]> {
267
- const config = await parseWranglerConfig(projectPath);
268
-
269
- if (!config.r2_buckets || config.r2_buckets.length === 0) {
270
- return [];
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
- const results: string[] = [];
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
- return results;
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
- export const CONFIG_DIR = join(homedir(), ".config", "jack");
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
  /**
@@ -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,