@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -44,6 +44,7 @@
44
44
  "@inquirer/prompts": "^7.0.0",
45
45
  "@modelcontextprotocol/sdk": "^1.25.1",
46
46
  "archiver": "^7.0.1",
47
+ "fflate": "^0.8.2",
47
48
  "human-id": "^4.1.3",
48
49
  "meow": "^14.0.0",
49
50
  "yocto-spinner": "^1.0.0",
@@ -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 });
@@ -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(
@@ -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,107 @@
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
+ // Pure Vite SPAs use assets-only mode (no worker entry)
37
+ // Cloudflare auto-generates a worker that serves static files
38
+ return {
39
+ name: projectName,
40
+ compatibility_date: COMPATIBILITY_DATE,
41
+ assets: {
42
+ directory: "./dist",
43
+ },
44
+ };
45
+
46
+ case "hono":
47
+ return {
48
+ name: projectName,
49
+ main: entryPoint || "src/index.ts",
50
+ compatibility_date: COMPATIBILITY_DATE,
51
+ };
52
+
53
+ case "sveltekit":
54
+ return {
55
+ name: projectName,
56
+ compatibility_date: COMPATIBILITY_DATE,
57
+ assets: {
58
+ directory: "./.svelte-kit/cloudflare",
59
+ },
60
+ };
61
+
62
+ default:
63
+ return {
64
+ name: projectName,
65
+ main: entryPoint || "src/index.ts",
66
+ compatibility_date: COMPATIBILITY_DATE,
67
+ };
68
+ }
69
+ }
70
+
71
+ export function writeWranglerConfig(projectPath: string, config: WranglerConfig): void {
72
+ const header = "// wrangler.jsonc (auto-generated by jack)\n";
73
+ const json = JSON.stringify(config, null, 2);
74
+ const content = `${header}${json}\n`;
75
+ const filePath = path.join(projectPath, "wrangler.jsonc");
76
+ fs.writeFileSync(filePath, content, "utf-8");
77
+ }
78
+
79
+ export function getDefaultProjectName(projectPath: string): string {
80
+ const packageJsonPath = path.join(projectPath, "package.json");
81
+
82
+ if (fs.existsSync(packageJsonPath)) {
83
+ try {
84
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
85
+ if (packageJson.name && typeof packageJson.name === "string") {
86
+ const slugified = slugify(packageJson.name);
87
+ if (slugified) {
88
+ return slugified;
89
+ }
90
+ }
91
+ } catch {
92
+ // Fall through to folder name
93
+ }
94
+ }
95
+
96
+ const folderName = path.basename(path.resolve(projectPath));
97
+ return slugify(folderName) || "my-project";
98
+ }
99
+
100
+ export function slugify(name: string): string {
101
+ return name
102
+ .toLowerCase()
103
+ .replace(/[\s_]+/g, "-")
104
+ .replace(/[^a-z0-9-]/g, "")
105
+ .replace(/-+/g, "-")
106
+ .replace(/^-+|-+$/g, "");
107
+ }
@@ -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,