@getjack/jack 0.1.26 → 0.1.27

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Storage (R2 bucket) creation logic for jack services storage create
3
+ *
4
+ * Handles both managed (control plane) and BYO (wrangler r2 bucket create) modes.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { $ } from "bun";
9
+ import { createProjectResource } from "../control-plane.ts";
10
+ import { readProjectLink } from "../project-link.ts";
11
+ import { getProjectNameFromDir } from "../storage/index.ts";
12
+ import {
13
+ addR2Binding,
14
+ generateBucketName,
15
+ getExistingR2Bindings,
16
+ toStorageBindingName,
17
+ } from "./storage-config.ts";
18
+
19
+ export interface CreateStorageBucketOptions {
20
+ name?: string;
21
+ interactive?: boolean; // Whether to prompt for deploy
22
+ }
23
+
24
+ export interface CreateStorageBucketResult {
25
+ bucketName: string;
26
+ bindingName: string;
27
+ created: boolean; // false if reused existing
28
+ }
29
+
30
+ interface ExistingBucket {
31
+ name: string;
32
+ creation_date?: string;
33
+ }
34
+
35
+ /**
36
+ * List all R2 buckets in the Cloudflare account via wrangler
37
+ */
38
+ async function listBucketsViaWrangler(): Promise<ExistingBucket[]> {
39
+ const result = await $`wrangler r2 bucket list --json`.nothrow().quiet();
40
+
41
+ if (result.exitCode !== 0) {
42
+ // If wrangler fails, return empty list (might not be logged in)
43
+ return [];
44
+ }
45
+
46
+ try {
47
+ const output = result.stdout.toString().trim();
48
+ const data = JSON.parse(output);
49
+ // wrangler r2 bucket list --json returns array: [{ "name": "...", "creation_date": "..." }]
50
+ return Array.isArray(data) ? data : [];
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Find an existing R2 bucket by name
58
+ */
59
+ async function findExistingBucket(bucketName: string): Promise<ExistingBucket | null> {
60
+ const buckets = await listBucketsViaWrangler();
61
+ return buckets.find((b) => b.name === bucketName) ?? null;
62
+ }
63
+
64
+ /**
65
+ * Create an R2 bucket via wrangler (for BYO mode)
66
+ */
67
+ async function createBucketViaWrangler(bucketName: string): Promise<{ created: boolean }> {
68
+ // Check if bucket already exists
69
+ const existing = await findExistingBucket(bucketName);
70
+ if (existing) {
71
+ return { created: false };
72
+ }
73
+
74
+ const result = await $`wrangler r2 bucket create ${bucketName}`.nothrow().quiet();
75
+
76
+ if (result.exitCode !== 0) {
77
+ const stderr = result.stderr.toString().trim();
78
+ throw new Error(stderr || `Failed to create bucket ${bucketName}`);
79
+ }
80
+
81
+ return { created: true };
82
+ }
83
+
84
+ /**
85
+ * Create an R2 storage bucket for the current project.
86
+ *
87
+ * For managed projects: calls control plane POST /v1/projects/:id/resources/r2
88
+ * For BYO projects: uses wrangler r2 bucket create
89
+ *
90
+ * In both cases, updates wrangler.jsonc with the new binding.
91
+ */
92
+ export async function createStorageBucket(
93
+ projectDir: string,
94
+ options: CreateStorageBucketOptions = {},
95
+ ): Promise<CreateStorageBucketResult> {
96
+ // Read project link to determine deploy mode
97
+ const link = await readProjectLink(projectDir);
98
+ if (!link) {
99
+ throw new Error("Not in a jack project. Run 'jack new' to create a project.");
100
+ }
101
+
102
+ // Get project name from wrangler config
103
+ const projectName = await getProjectNameFromDir(projectDir);
104
+
105
+ // Get existing R2 bindings to determine naming
106
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
107
+ const existingBindings = await getExistingR2Bindings(wranglerPath);
108
+ const existingCount = existingBindings.length;
109
+
110
+ // Determine bucket name
111
+ const bucketName = options.name ?? generateBucketName(projectName, existingCount);
112
+
113
+ // Determine binding name
114
+ const isFirst = existingCount === 0;
115
+ const bindingName = toStorageBindingName(bucketName, isFirst);
116
+
117
+ // Check if binding name already exists
118
+ const bindingExists = existingBindings.some((b) => b.binding === bindingName);
119
+ if (bindingExists) {
120
+ throw new Error(`Binding "${bindingName}" already exists. Choose a different bucket name.`);
121
+ }
122
+
123
+ let created = true;
124
+ let actualBucketName = bucketName;
125
+
126
+ if (link.deploy_mode === "managed") {
127
+ // Managed mode: call control plane
128
+ // Note: Control plane will reuse existing bucket if name matches
129
+ const resource = await createProjectResource(link.project_id, "r2", {
130
+ name: bucketName,
131
+ bindingName,
132
+ });
133
+ // Use the actual name from control plane (may differ from CLI-generated name)
134
+ actualBucketName = resource.resource_name;
135
+ } else {
136
+ // BYO mode: use wrangler r2 bucket create (checks for existing first)
137
+ const result = await createBucketViaWrangler(bucketName);
138
+ created = result.created;
139
+ }
140
+
141
+ // Update wrangler.jsonc with the new binding
142
+ await addR2Binding(wranglerPath, {
143
+ binding: bindingName,
144
+ bucket_name: actualBucketName,
145
+ });
146
+
147
+ return {
148
+ bucketName: actualBucketName,
149
+ bindingName,
150
+ created,
151
+ };
152
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Storage (R2 bucket) deletion logic for jack services storage delete
3
+ *
4
+ * Handles both managed (control plane) and BYO (wrangler r2 bucket delete) modes.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { $ } from "bun";
9
+ import { deleteProjectResource, fetchProjectResources } from "../control-plane.ts";
10
+ import { readProjectLink } from "../project-link.ts";
11
+ import { getExistingR2Bindings, removeR2Binding } from "./storage-config.ts";
12
+
13
+ export interface DeleteStorageBucketResult {
14
+ bucketName: string;
15
+ deleted: boolean;
16
+ bindingRemoved: boolean;
17
+ }
18
+
19
+ /**
20
+ * Delete an R2 bucket via wrangler (for BYO mode)
21
+ */
22
+ async function deleteBucketViaWrangler(bucketName: string): Promise<void> {
23
+ const result = await $`wrangler r2 bucket delete ${bucketName}`.nothrow().quiet();
24
+
25
+ if (result.exitCode !== 0) {
26
+ const stderr = result.stderr.toString().trim();
27
+ // Ignore "not found" errors - bucket may already be deleted
28
+ if (!stderr.includes("not found") && !stderr.includes("does not exist")) {
29
+ throw new Error(stderr || `Failed to delete bucket ${bucketName}`);
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Delete an R2 storage bucket for the current project.
36
+ *
37
+ * For managed projects: calls control plane DELETE /v1/projects/:id/resources/:id
38
+ * For BYO projects: uses wrangler r2 bucket delete
39
+ *
40
+ * In both cases, removes the binding from wrangler.jsonc.
41
+ */
42
+ export async function deleteStorageBucket(
43
+ projectDir: string,
44
+ bucketName: string,
45
+ ): Promise<DeleteStorageBucketResult> {
46
+ // Read project link to determine deploy mode
47
+ const link = await readProjectLink(projectDir);
48
+ if (!link) {
49
+ throw new Error("Not in a jack project. Run 'jack new' to create a project.");
50
+ }
51
+
52
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
53
+
54
+ // Verify bucket exists in wrangler.jsonc
55
+ const bindings = await getExistingR2Bindings(wranglerPath);
56
+ const binding = bindings.find((b) => b.bucket_name === bucketName);
57
+
58
+ if (!binding) {
59
+ throw new Error(`Bucket "${bucketName}" not found in this project.`);
60
+ }
61
+
62
+ let deleted = true;
63
+
64
+ if (link.deploy_mode === "managed") {
65
+ // Managed mode: delete via control plane (don't call wrangler - user may not have CF auth)
66
+ const resources = await fetchProjectResources(link.project_id);
67
+ const r2Resource = resources.find(
68
+ (r) => r.resource_type === "r2" && r.resource_name === bucketName,
69
+ );
70
+
71
+ if (r2Resource) {
72
+ await deleteProjectResource(link.project_id, r2Resource.id);
73
+ }
74
+ // If resource not in control plane, just remove the local binding
75
+ // Don't attempt wrangler delete - managed mode users don't have CF credentials
76
+ } else {
77
+ // BYO mode: delete via wrangler directly
78
+ await deleteBucketViaWrangler(bucketName);
79
+ }
80
+
81
+ // Remove binding from wrangler.jsonc (both modes)
82
+ const bindingRemoved = await removeR2Binding(wranglerPath, bucketName);
83
+
84
+ return {
85
+ bucketName,
86
+ deleted,
87
+ bindingRemoved,
88
+ };
89
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Storage (R2 bucket) info logic for jack services storage info
3
+ *
4
+ * Gets bucket information. For now, just confirms the bucket exists
5
+ * since R2 doesn't have a simple stats API via wrangler.
6
+ */
7
+
8
+ import { $ } from "bun";
9
+ import { join } from "node:path";
10
+ import { fetchProjectResources } from "../control-plane.ts";
11
+ import { readProjectLink } from "../project-link.ts";
12
+ import { getExistingR2Bindings } from "./storage-config.ts";
13
+
14
+ export interface StorageBucketInfo {
15
+ name: string;
16
+ binding: string;
17
+ source: "control-plane" | "wrangler";
18
+ }
19
+
20
+ /**
21
+ * Check if a bucket exists via wrangler
22
+ */
23
+ async function bucketExistsViaWrangler(bucketName: string): Promise<boolean> {
24
+ const result = await $`wrangler r2 bucket list --json`.nothrow().quiet();
25
+
26
+ if (result.exitCode !== 0) {
27
+ return false;
28
+ }
29
+
30
+ try {
31
+ const output = result.stdout.toString().trim();
32
+ const buckets = JSON.parse(output) as Array<{ name: string }>;
33
+ return buckets.some((b) => b.name === bucketName);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Get storage bucket info for a project.
41
+ *
42
+ * @param projectDir - The project directory
43
+ * @param bucketName - Optional specific bucket name. If not provided, returns first bucket.
44
+ */
45
+ export async function getStorageBucketInfo(
46
+ projectDir: string,
47
+ bucketName?: string,
48
+ ): Promise<StorageBucketInfo | null> {
49
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
50
+
51
+ // Read deploy mode from .jack/project.json
52
+ const link = await readProjectLink(projectDir);
53
+
54
+ // Get bindings from wrangler.jsonc
55
+ const bindings = await getExistingR2Bindings(wranglerPath);
56
+
57
+ if (bindings.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ // Find the requested bucket (or first if not specified)
62
+ const binding = bucketName
63
+ ? bindings.find((b) => b.bucket_name === bucketName)
64
+ : bindings[0];
65
+
66
+ if (!binding) {
67
+ return null;
68
+ }
69
+
70
+ // For managed projects, verify via control plane (don't call wrangler - user may not have CF auth)
71
+ if (link?.deploy_mode === "managed") {
72
+ const resources = await fetchProjectResources(link.project_id);
73
+ const r2Resource = resources.find(
74
+ (r) => r.resource_type === "r2" && r.resource_name === binding.bucket_name,
75
+ );
76
+
77
+ if (r2Resource) {
78
+ return {
79
+ name: binding.bucket_name,
80
+ binding: binding.binding,
81
+ source: "control-plane",
82
+ };
83
+ }
84
+ // For managed mode: if not in control plane, the bucket doesn't exist on the server
85
+ // but we still have a binding configured, so return the binding info
86
+ return {
87
+ name: binding.bucket_name,
88
+ binding: binding.binding,
89
+ source: "control-plane",
90
+ };
91
+ }
92
+
93
+ // For BYO only, verify bucket exists via wrangler
94
+ const exists = await bucketExistsViaWrangler(binding.bucket_name);
95
+
96
+ if (!exists) {
97
+ return null;
98
+ }
99
+
100
+ return {
101
+ name: binding.bucket_name,
102
+ binding: binding.binding,
103
+ source: "wrangler",
104
+ };
105
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Storage (R2 bucket) listing logic for jack services storage list
3
+ *
4
+ * Lists R2 buckets configured in wrangler.jsonc.
5
+ * For managed projects, fetches metadata via control plane instead of wrangler.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { getExistingR2Bindings } from "./storage-config.ts";
10
+
11
+ export interface StorageBucketListEntry {
12
+ name: string;
13
+ binding: string;
14
+ }
15
+
16
+ /**
17
+ * List all R2 storage buckets configured for a project.
18
+ *
19
+ * For managed projects: reads bindings from wrangler.jsonc.
20
+ * For BYO projects: reads bindings from wrangler.jsonc.
21
+ *
22
+ * Note: R2 doesn't have a simple metadata API like D1, so we just return
23
+ * the configured bindings. For detailed info, use storage info.
24
+ */
25
+ export async function listStorageBuckets(projectDir: string): Promise<StorageBucketListEntry[]> {
26
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
27
+
28
+ // Get existing R2 bindings from wrangler.jsonc
29
+ const bindings = await getExistingR2Bindings(wranglerPath);
30
+
31
+ if (bindings.length === 0) {
32
+ return [];
33
+ }
34
+
35
+ // Convert to list entries
36
+ const entries: StorageBucketListEntry[] = bindings.map((binding) => ({
37
+ name: binding.bucket_name,
38
+ binding: binding.binding,
39
+ }));
40
+
41
+ return entries;
42
+ }
@@ -45,7 +45,7 @@ export function registerResources(
45
45
  version: packageJson.version,
46
46
  services: {
47
47
  supported: ["d1", "kv", "r2"],
48
- create_supported: ["d1"],
48
+ create_supported: ["d1", "r2"],
49
49
  },
50
50
  guidance: {
51
51
  prefer_jack_over_wrangler: true,