@getjack/jack 0.1.4 → 0.1.6

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 (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/login.ts +124 -1
  8. package/src/commands/logs.ts +8 -18
  9. package/src/commands/new.ts +7 -1
  10. package/src/commands/projects.ts +166 -105
  11. package/src/commands/secrets.ts +7 -6
  12. package/src/commands/services.ts +5 -4
  13. package/src/commands/tag.ts +282 -0
  14. package/src/commands/unlink.ts +30 -0
  15. package/src/index.ts +46 -1
  16. package/src/lib/auth/index.ts +2 -0
  17. package/src/lib/auth/store.ts +26 -2
  18. package/src/lib/binding-validator.ts +4 -13
  19. package/src/lib/build-helper.ts +93 -5
  20. package/src/lib/control-plane.ts +137 -0
  21. package/src/lib/deploy-mode.ts +1 -1
  22. package/src/lib/managed-deploy.ts +11 -1
  23. package/src/lib/managed-down.ts +7 -20
  24. package/src/lib/paths-index.test.ts +546 -0
  25. package/src/lib/paths-index.ts +310 -0
  26. package/src/lib/project-link.test.ts +459 -0
  27. package/src/lib/project-link.ts +279 -0
  28. package/src/lib/project-list.test.ts +581 -0
  29. package/src/lib/project-list.ts +449 -0
  30. package/src/lib/project-operations.ts +304 -183
  31. package/src/lib/project-resolver.ts +191 -211
  32. package/src/lib/tags.ts +389 -0
  33. package/src/lib/telemetry.ts +86 -157
  34. package/src/lib/zip-packager.ts +9 -0
  35. package/src/templates/index.ts +5 -3
  36. package/templates/api/.jack/template.json +4 -0
  37. package/templates/hello/.jack/template.json +4 -0
  38. package/templates/miniapp/.jack/template.json +4 -0
  39. package/templates/nextjs/.jack.json +28 -0
  40. package/templates/nextjs/app/globals.css +9 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -0,0 +1,282 @@
1
+ /**
2
+ * jack tag - Manage project tags
3
+ *
4
+ * Usage:
5
+ * jack tag add <tags...> Add tags to current project
6
+ * jack tag add <project> <tags...> Add tags to named project
7
+ * jack tag remove <tags...> Remove tags from current project
8
+ * jack tag remove <project> <tags...> Remove tags from named project
9
+ * jack tag list List all tags across projects
10
+ * jack tag list [project] List tags for a specific project
11
+ */
12
+
13
+ import { error, info, item, success } from "../lib/output.ts";
14
+ import { readProjectLink } from "../lib/project-link.ts";
15
+ import {
16
+ addTags,
17
+ findProjectPathByName,
18
+ getAllTagsWithCounts,
19
+ getProjectTags,
20
+ removeTags,
21
+ validateTags,
22
+ } from "../lib/tags.ts";
23
+
24
+ export default async function tag(subcommand?: string, args: string[] = []): Promise<void> {
25
+ if (!subcommand) {
26
+ showHelp();
27
+ return;
28
+ }
29
+
30
+ switch (subcommand) {
31
+ case "add":
32
+ return await addTagsCommand(args);
33
+ case "remove":
34
+ return await removeTagsCommand(args);
35
+ case "list":
36
+ return await listTagsCommand(args);
37
+ case "--help":
38
+ case "-h":
39
+ case "help":
40
+ showHelp();
41
+ return;
42
+ default:
43
+ error(`Unknown subcommand: ${subcommand}`);
44
+ info("Available: add, remove, list");
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Show help for the tag command
51
+ */
52
+ function showHelp(): void {
53
+ console.error(`
54
+ jack tag - Manage project tags
55
+
56
+ Usage
57
+ $ jack tag add <tags...> Add tags to current project
58
+ $ jack tag add <project> <tags...> Add tags to named project
59
+ $ jack tag remove <tags...> Remove tags from current project
60
+ $ jack tag remove <project> <tags...> Remove tags from named project
61
+ $ jack tag list List all tags across projects
62
+ $ jack tag list [project] List tags for a specific project
63
+
64
+ Tag Format
65
+ Tags must be lowercase alphanumeric with optional colons and hyphens.
66
+ Examples: backend, api:v2, my-service, prod
67
+
68
+ Examples
69
+ $ jack tag add backend api Add tags in project directory
70
+ $ jack tag add my-app backend Add tag to my-app project
71
+ $ jack tag remove deprecated Remove tag from current project
72
+ $ jack tag list Show all tags with counts
73
+ $ jack tag list my-app Show tags for my-app
74
+ `);
75
+ }
76
+
77
+ /**
78
+ * Resolve project path from arguments
79
+ * Returns [projectPath, remainingArgs]
80
+ *
81
+ * Logic:
82
+ * 1. If in a linked project directory, use cwd and all args are tags
83
+ * 2. If not in project directory, first arg might be project name
84
+ */
85
+ async function resolveProjectAndTags(args: string[]): Promise<[string | null, string[]]> {
86
+ const cwd = process.cwd();
87
+
88
+ // Check if we're in a linked project directory
89
+ const link = await readProjectLink(cwd);
90
+
91
+ if (link) {
92
+ // In a project directory - all args are tags
93
+ return [cwd, args];
94
+ }
95
+
96
+ // Not in a project directory - first arg might be project name
97
+ if (args.length === 0) {
98
+ return [null, []];
99
+ }
100
+
101
+ const firstArg = args[0] as string; // Safe: we checked args.length > 0 above
102
+ const rest = args.slice(1);
103
+
104
+ // Try to find project by name
105
+ const projectPath = await findProjectPathByName(firstArg);
106
+
107
+ if (projectPath) {
108
+ // First arg was a project name
109
+ return [projectPath, rest];
110
+ }
111
+
112
+ // First arg wasn't a project name - we're not in a project directory
113
+ // and couldn't find a matching project
114
+ return [null, args];
115
+ }
116
+
117
+ /**
118
+ * Add tags to a project
119
+ */
120
+ async function addTagsCommand(args: string[]): Promise<void> {
121
+ const [projectPath, tagArgs] = await resolveProjectAndTags(args);
122
+
123
+ if (!projectPath) {
124
+ error("Not in a project directory and no valid project name provided");
125
+ info("Run from a project directory or specify project name: jack tag add <project> <tags...>");
126
+ process.exit(1);
127
+ }
128
+
129
+ if (tagArgs.length === 0) {
130
+ error("No tags specified");
131
+ info("Usage: jack tag add <tags...>");
132
+ process.exit(1);
133
+ }
134
+
135
+ // Validate tags first
136
+ const validation = validateTags(tagArgs);
137
+ if (!validation.valid) {
138
+ error("Invalid tags:");
139
+ for (const { tag, reason } of validation.invalidTags) {
140
+ item(`"${tag}": ${reason}`);
141
+ }
142
+ process.exit(1);
143
+ }
144
+
145
+ const result = await addTags(projectPath, tagArgs);
146
+
147
+ if (!result.success) {
148
+ error(result.error || "Failed to add tags");
149
+ process.exit(1);
150
+ }
151
+
152
+ if (result.added && result.added.length > 0) {
153
+ success(`Added tags: ${result.added.join(", ")}`);
154
+ }
155
+
156
+ if (result.skipped && result.skipped.length > 0) {
157
+ info(`Already present: ${result.skipped.join(", ")}`);
158
+ }
159
+
160
+ if (result.tags.length > 0) {
161
+ info(`Current tags: ${result.tags.join(", ")}`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Remove tags from a project
167
+ */
168
+ async function removeTagsCommand(args: string[]): Promise<void> {
169
+ const [projectPath, tagArgs] = await resolveProjectAndTags(args);
170
+
171
+ if (!projectPath) {
172
+ error("Not in a project directory and no valid project name provided");
173
+ info(
174
+ "Run from a project directory or specify project name: jack tag remove <project> <tags...>",
175
+ );
176
+ process.exit(1);
177
+ }
178
+
179
+ if (tagArgs.length === 0) {
180
+ error("No tags specified");
181
+ info("Usage: jack tag remove <tags...>");
182
+ process.exit(1);
183
+ }
184
+
185
+ const result = await removeTags(projectPath, tagArgs);
186
+
187
+ if (!result.success) {
188
+ error(result.error || "Failed to remove tags");
189
+ process.exit(1);
190
+ }
191
+
192
+ if (result.removed && result.removed.length > 0) {
193
+ success(`Removed tags: ${result.removed.join(", ")}`);
194
+ }
195
+
196
+ if (result.skipped && result.skipped.length > 0) {
197
+ info(`Not found: ${result.skipped.join(", ")}`);
198
+ }
199
+
200
+ if (result.tags.length > 0) {
201
+ info(`Remaining tags: ${result.tags.join(", ")}`);
202
+ } else {
203
+ info("No tags remaining");
204
+ }
205
+ }
206
+
207
+ /**
208
+ * List tags for a project or all tags across projects
209
+ */
210
+ async function listTagsCommand(args: string[]): Promise<void> {
211
+ const [projectArg] = args;
212
+
213
+ if (projectArg) {
214
+ // List tags for a specific project
215
+ await listProjectTags(projectArg);
216
+ } else {
217
+ // Check if we're in a project directory
218
+ const cwd = process.cwd();
219
+ const link = await readProjectLink(cwd);
220
+
221
+ if (link) {
222
+ // In a project directory - show tags for this project
223
+ await listProjectTagsForPath(cwd);
224
+ } else {
225
+ // Not in project directory - show all tags
226
+ await listAllTags();
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * List tags for a specific project by name
233
+ */
234
+ async function listProjectTags(projectName: string): Promise<void> {
235
+ const projectPath = await findProjectPathByName(projectName);
236
+
237
+ if (!projectPath) {
238
+ error(`Project not found: ${projectName}`);
239
+ process.exit(1);
240
+ }
241
+
242
+ await listProjectTagsForPath(projectPath);
243
+ }
244
+
245
+ /**
246
+ * List tags for a project at a specific path
247
+ */
248
+ async function listProjectTagsForPath(projectPath: string): Promise<void> {
249
+ const tags = await getProjectTags(projectPath);
250
+
251
+ console.error("");
252
+ if (tags.length === 0) {
253
+ info("No tags for this project");
254
+ info("Add tags with: jack tag add <tags...>");
255
+ } else {
256
+ info(`Tags (${tags.length}):`);
257
+ for (const tag of tags) {
258
+ item(tag);
259
+ }
260
+ }
261
+ console.error("");
262
+ }
263
+
264
+ /**
265
+ * List all tags across all projects with counts
266
+ */
267
+ async function listAllTags(): Promise<void> {
268
+ const tagCounts = await getAllTagsWithCounts();
269
+
270
+ console.error("");
271
+ if (tagCounts.length === 0) {
272
+ info("No tags found across any projects");
273
+ info("Add tags with: jack tag add <tags...>");
274
+ } else {
275
+ info(`All tags (${tagCounts.length}):`);
276
+ for (const { tag, count } of tagCounts) {
277
+ const projectLabel = count === 1 ? "project" : "projects";
278
+ item(`${tag} (${count} ${projectLabel})`);
279
+ }
280
+ }
281
+ console.error("");
282
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * jack unlink - Remove .jack/ directory from current project
3
+ *
4
+ * Removes the local project link but does NOT delete the project from cloud.
5
+ * You can re-link anytime with: jack link
6
+ */
7
+
8
+ import { error, info, success } from "../lib/output.ts";
9
+ import { unregisterPath } from "../lib/paths-index.ts";
10
+ import { readProjectLink, unlinkProject } from "../lib/project-link.ts";
11
+
12
+ export default async function unlink(): Promise<void> {
13
+ // Check if linked
14
+ const link = await readProjectLink(process.cwd());
15
+
16
+ if (!link) {
17
+ error("This directory is not linked");
18
+ info("Nothing to unlink");
19
+ process.exit(1);
20
+ }
21
+
22
+ // Remove from paths index
23
+ await unregisterPath(link.project_id, process.cwd());
24
+
25
+ // Remove .jack/ directory
26
+ await unlinkProject(process.cwd());
27
+
28
+ success("Project unlinked");
29
+ info("You can re-link with: jack link");
30
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import pkg from "../package.json";
4
4
  import { enableDebug } from "./lib/debug.ts";
5
5
  import { isJackError } from "./lib/errors.ts";
6
6
  import { info, error as printError } from "./lib/output.ts";
7
- import { identify, shutdown, withTelemetry } from "./lib/telemetry.ts";
7
+ import { getEnvironmentProps, identify, shutdown, withTelemetry } from "./lib/telemetry.ts";
8
8
 
9
9
  const cli = meow(
10
10
  `
@@ -35,6 +35,11 @@ const cli = meow(
35
35
  logout Sign out
36
36
  whoami Show current user
37
37
 
38
+ Project Management
39
+ link [name] Link directory to a project
40
+ unlink Remove project link
41
+ tag Manage project tags
42
+
38
43
  Advanced
39
44
  agents Manage AI agent configs
40
45
  secrets Manage project secrets
@@ -105,6 +110,18 @@ const cli = meow(
105
110
  type: "boolean",
106
111
  default: false,
107
112
  },
113
+ all: {
114
+ type: "boolean",
115
+ shortFlag: "a",
116
+ default: false,
117
+ },
118
+ status: {
119
+ type: "string",
120
+ },
121
+ json: {
122
+ type: "boolean",
123
+ default: false,
124
+ },
108
125
  project: {
109
126
  type: "string",
110
127
  shortFlag: "p",
@@ -125,6 +142,10 @@ const cli = meow(
125
142
  type: "boolean",
126
143
  default: false,
127
144
  },
145
+ tag: {
146
+ type: "string",
147
+ isMultiple: true,
148
+ },
128
149
  },
129
150
  },
130
151
  );
@@ -143,6 +164,7 @@ identify({
143
164
  arch: process.arch,
144
165
  node_version: process.version,
145
166
  is_ci: !!process.env.CI,
167
+ ...getEnvironmentProps(),
146
168
  });
147
169
 
148
170
  try {
@@ -200,6 +222,11 @@ try {
200
222
  });
201
223
  break;
202
224
  }
225
+ case "tag": {
226
+ const { default: tag } = await import("./commands/tag.ts");
227
+ await withTelemetry("tag", tag)(args[0], args.slice(1));
228
+ break;
229
+ }
203
230
  case "sync": {
204
231
  const { default: sync } = await import("./commands/sync.ts");
205
232
  await withTelemetry(
@@ -279,6 +306,14 @@ try {
279
306
  if (cli.flags.local) lsArgs.push("--local");
280
307
  if (cli.flags.deployed) lsArgs.push("--deployed");
281
308
  if (cli.flags.cloud) lsArgs.push("--cloud");
309
+ if (cli.flags.all) lsArgs.push("--all");
310
+ if (cli.flags.json) lsArgs.push("--json");
311
+ if (cli.flags.status) lsArgs.push("--status", cli.flags.status);
312
+ if (cli.flags.tag) {
313
+ for (const t of cli.flags.tag) {
314
+ lsArgs.push("--tag", t);
315
+ }
316
+ }
282
317
  await withTelemetry("projects", projects)("list", lsArgs);
283
318
  break;
284
319
  }
@@ -307,6 +342,16 @@ try {
307
342
  await withTelemetry("feedback", feedback)();
308
343
  break;
309
344
  }
345
+ case "link": {
346
+ const { default: link } = await import("./commands/link.ts");
347
+ await withTelemetry("link", link)(args[0], { byo: cli.flags.byo });
348
+ break;
349
+ }
350
+ case "unlink": {
351
+ const { default: unlink } = await import("./commands/unlink.ts");
352
+ await withTelemetry("unlink", unlink)();
353
+ break;
354
+ }
310
355
  default:
311
356
  cli.showHelp(command ? 1 : 0);
312
357
  }
@@ -9,10 +9,12 @@ export {
9
9
  export { requireAuth, requireAuthOrLogin, getCurrentUser } from "./guard.ts";
10
10
  export {
11
11
  deleteCredentials,
12
+ getAuthState,
12
13
  getCredentials,
13
14
  isLoggedIn,
14
15
  isTokenExpired,
15
16
  saveCredentials,
16
17
  type AuthCredentials,
18
+ type AuthState,
17
19
  type AuthUser,
18
20
  } from "./store.ts";
@@ -42,9 +42,33 @@ export async function deleteCredentials(): Promise<void> {
42
42
  }
43
43
  }
44
44
 
45
- export async function isLoggedIn(): Promise<boolean> {
45
+ export type AuthState = "logged-in" | "not-logged-in" | "session-expired";
46
+
47
+ /**
48
+ * Get detailed auth state
49
+ * - "logged-in": valid token (or successfully refreshed)
50
+ * - "not-logged-in": no credentials stored
51
+ * - "session-expired": had credentials but refresh failed
52
+ */
53
+ export async function getAuthState(): Promise<AuthState> {
46
54
  const creds = await getCredentials();
47
- return creds !== null;
55
+ if (!creds) return "not-logged-in";
56
+
57
+ // If token is not expired, we're logged in
58
+ if (!isTokenExpired(creds)) return "logged-in";
59
+
60
+ // If expired, try to refresh (dynamic import to avoid circular dep)
61
+ try {
62
+ const { getValidAccessToken } = await import("./client.ts");
63
+ const token = await getValidAccessToken();
64
+ return token !== null ? "logged-in" : "session-expired";
65
+ } catch {
66
+ return "session-expired";
67
+ }
68
+ }
69
+
70
+ export async function isLoggedIn(): Promise<boolean> {
71
+ return (await getAuthState()) === "logged-in";
48
72
  }
49
73
 
50
74
  export function isTokenExpired(creds: AuthCredentials): boolean {
@@ -12,7 +12,7 @@ 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"] as const;
15
+ export const SUPPORTED_BINDINGS = ["d1_databases", "ai", "assets", "vars", "r2_buckets"] as const;
16
16
 
17
17
  /**
18
18
  * Bindings not yet supported by jack cloud.
@@ -23,7 +23,6 @@ export const UNSUPPORTED_BINDINGS = [
23
23
  "durable_objects",
24
24
  "queues",
25
25
  "services",
26
- "r2_buckets",
27
26
  "hyperdrive",
28
27
  "vectorize",
29
28
  "browser",
@@ -38,7 +37,6 @@ const BINDING_DISPLAY_NAMES: Record<string, string> = {
38
37
  durable_objects: "Durable Objects",
39
38
  queues: "Queues",
40
39
  services: "Service Bindings",
41
- r2_buckets: "R2 Buckets",
42
40
  hyperdrive: "Hyperdrive",
43
41
  vectorize: "Vectorize",
44
42
  browser: "Browser Rendering",
@@ -68,16 +66,9 @@ export function validateBindings(
68
66
  const value = config[binding as keyof WranglerConfig];
69
67
  if (value !== undefined && value !== null) {
70
68
  const displayName = BINDING_DISPLAY_NAMES[binding] || binding;
71
- // Special message for R2 - suggest using Workers Assets instead
72
- if (binding === "r2_buckets") {
73
- errors.push(
74
- `✗ R2 buckets not supported in managed deploy.\n For static files, use Workers Assets instead (assets.directory in wrangler.jsonc).\n Fix: Replace r2_buckets with assets config, or use 'wrangler deploy' for full control.`,
75
- );
76
- } else {
77
- errors.push(
78
- `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
79
- );
80
- }
69
+ 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.`,
71
+ );
81
72
  }
82
73
  }
83
74
 
@@ -36,12 +36,15 @@ export interface WranglerConfig {
36
36
  run_worker_first?: boolean;
37
37
  };
38
38
  vars?: Record<string, string>;
39
+ r2_buckets?: Array<{
40
+ binding: string;
41
+ bucket_name: string;
42
+ }>;
39
43
  // Unsupported bindings (for validation)
40
44
  kv_namespaces?: unknown;
41
45
  durable_objects?: unknown;
42
46
  queues?: unknown;
43
47
  services?: unknown;
44
- r2_buckets?: unknown;
45
48
  hyperdrive?: unknown;
46
49
  vectorize?: unknown;
47
50
  browser?: unknown;
@@ -82,6 +85,18 @@ export async function needsViteBuild(projectPath: string): Promise<boolean> {
82
85
  );
83
86
  }
84
87
 
88
+ /**
89
+ * Checks if project requires OpenNext build by detecting open-next config files
90
+ * @param projectPath - Absolute path to project directory
91
+ * @returns true if open-next.config.ts or open-next.config.js exists
92
+ */
93
+ export async function needsOpenNextBuild(projectPath: string): Promise<boolean> {
94
+ return (
95
+ existsSync(join(projectPath, "open-next.config.ts")) ||
96
+ existsSync(join(projectPath, "open-next.config.js"))
97
+ );
98
+ }
99
+
85
100
  /**
86
101
  * Runs Vite build for the project
87
102
  * @param projectPath - Absolute path to project directory
@@ -105,6 +120,29 @@ export async function runViteBuild(projectPath: string): Promise<void> {
105
120
  }
106
121
  }
107
122
 
123
+ /**
124
+ * Runs OpenNext build for Next.js projects targeting Cloudflare
125
+ * @param projectPath - Absolute path to project directory
126
+ * @throws JackError if build fails
127
+ */
128
+ export async function runOpenNextBuild(projectPath: string): Promise<void> {
129
+ // OpenNext builds Next.js for Cloudflare Workers
130
+ // Outputs to .open-next/worker.js and .open-next/assets/
131
+ const buildResult = await $`bunx opennextjs-cloudflare build`.cwd(projectPath).nothrow().quiet();
132
+
133
+ if (buildResult.exitCode !== 0) {
134
+ throw new JackError(
135
+ JackErrorCode.BUILD_FAILED,
136
+ "OpenNext build failed",
137
+ "Check your next.config and source files for errors",
138
+ {
139
+ exitCode: buildResult.exitCode,
140
+ stderr: buildResult.stderr.toString(),
141
+ },
142
+ );
143
+ }
144
+ }
145
+
108
146
  /**
109
147
  * Builds a Cloudflare Worker project using wrangler dry-run
110
148
  * @param options - Build options with project path and optional reporter
@@ -117,13 +155,22 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
117
155
  // Parse wrangler config first
118
156
  const config = await parseWranglerConfig(projectPath);
119
157
 
120
- // Check if Vite build is needed and run it
158
+ // Check if OpenNext build is needed (Next.js + Cloudflare)
159
+ const hasOpenNext = await needsOpenNextBuild(projectPath);
160
+ if (hasOpenNext) {
161
+ reporter?.start("Building...");
162
+ await runOpenNextBuild(projectPath);
163
+ reporter?.stop();
164
+ reporter?.success("Built");
165
+ }
166
+
167
+ // Check if Vite build is needed and run it (skip if OpenNext already built)
121
168
  const hasVite = await needsViteBuild(projectPath);
122
- if (hasVite) {
123
- reporter?.start("Building with Vite...");
169
+ if (hasVite && !hasOpenNext) {
170
+ reporter?.start("Building...");
124
171
  await runViteBuild(projectPath);
125
172
  reporter?.stop();
126
- reporter?.success("Built with Vite");
173
+ reporter?.success("Built");
127
174
  }
128
175
 
129
176
  // Create unique temp directory for build output
@@ -209,3 +256,44 @@ async function resolveEntrypoint(outDir: string, main?: string): Promise<string>
209
256
  "Ensure wrangler outputs a single entry file (index.js or worker.js)",
210
257
  );
211
258
  }
259
+
260
+ /**
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
265
+ */
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 [];
271
+ }
272
+
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
+ }
297
+
298
+ return results;
299
+ }