@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.
- package/package.json +1 -1
- package/src/commands/logs.ts +74 -12
- package/src/commands/services.ts +255 -4
- package/src/index.ts +4 -1
- package/src/lib/control-plane.ts +39 -0
- package/src/lib/services/db-execute.ts +6 -3
- package/src/lib/services/storage-config.ts +669 -0
- package/src/lib/services/storage-create.ts +152 -0
- package/src/lib/services/storage-delete.ts +89 -0
- package/src/lib/services/storage-info.ts +105 -0
- package/src/lib/services/storage-list.ts +42 -0
- package/src/mcp/resources/index.ts +1 -1
- package/src/mcp/tools/index.ts +480 -0
|
@@ -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
|
+
}
|