@getjack/jack 0.1.14 → 0.1.16

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.
@@ -300,10 +300,18 @@ export interface ProjectResource {
300
300
  resource_type: string;
301
301
  resource_name: string;
302
302
  provider_id: string;
303
+ binding_name: string;
303
304
  status: string;
304
305
  created_at: string;
305
306
  }
306
307
 
308
+ export interface CreateResourceResponse {
309
+ resource_type: string;
310
+ resource_name: string;
311
+ provider_id: string;
312
+ binding_name: string;
313
+ }
314
+
307
315
  /**
308
316
  * Fetch all resources for a managed project.
309
317
  * Uses GET /v1/projects/:id/resources endpoint.
@@ -324,6 +332,45 @@ export async function fetchProjectResources(projectId: string): Promise<ProjectR
324
332
  return data.resources;
325
333
  }
326
334
 
335
+ /**
336
+ * Create a resource for a managed project.
337
+ * Uses POST /v1/projects/:id/resources/:type endpoint.
338
+ */
339
+ export async function createProjectResource(
340
+ projectId: string,
341
+ resourceType: "d1" | "kv" | "r2",
342
+ options?: { name?: string; bindingName?: string },
343
+ ): Promise<CreateResourceResponse> {
344
+ const { authFetch } = await import("./auth/index.ts");
345
+
346
+ const body: Record<string, string> = {};
347
+ if (options?.name) {
348
+ body.name = options.name;
349
+ }
350
+ if (options?.bindingName) {
351
+ body.binding_name = options.bindingName;
352
+ }
353
+
354
+ const response = await authFetch(
355
+ `${getControlApiUrl()}/v1/projects/${projectId}/resources/${resourceType}`,
356
+ {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify(body),
360
+ },
361
+ );
362
+
363
+ if (!response.ok) {
364
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
365
+ message?: string;
366
+ };
367
+ throw new Error(err.message || `Failed to create ${resourceType} resource: ${response.status}`);
368
+ }
369
+
370
+ const data = (await response.json()) as { resource: CreateResourceResponse };
371
+ return data.resource;
372
+ }
373
+
327
374
  /**
328
375
  * Sync project tags to the control plane.
329
376
  * Fire-and-forget: errors are logged but not thrown.
package/src/lib/hooks.ts CHANGED
@@ -83,50 +83,30 @@ const noopOutput: HookOutput = {
83
83
  */
84
84
 
85
85
  /**
86
- * Prompt user with numbered options (Claude Code style)
86
+ * Prompt user with numbered options
87
+ * Uses @clack/prompts selectKey for reliable input handling
87
88
  * Returns the selected option index (0-based) or -1 if cancelled
88
89
  */
89
90
  export async function promptSelect(options: string[]): Promise<number> {
90
- // Display options
91
- for (let i = 0; i < options.length; i++) {
92
- console.error(` ${i + 1}. ${options[i]}`);
93
- }
94
- console.error("");
95
- console.error(" Esc to skip");
96
-
97
- // Read single keypress
98
- if (process.stdin.isTTY) {
99
- process.stdin.setRawMode(true);
100
- }
101
- process.stdin.resume();
102
-
103
- return new Promise((resolve) => {
104
- const onData = (key: Buffer) => {
105
- const char = key.toString();
91
+ const { selectKey, isCancel } = await import("@clack/prompts");
106
92
 
107
- // Esc or q to cancel
108
- if (char === "\x1b" || char === "q") {
109
- cleanup();
110
- resolve(-1);
111
- return;
112
- }
93
+ // Build options with number keys (1, 2, 3, ...)
94
+ const clackOptions = options.map((label, index) => ({
95
+ value: String(index + 1),
96
+ label,
97
+ }));
113
98
 
114
- // Number keys
115
- const num = Number.parseInt(char, 10);
116
- if (num >= 1 && num <= options.length) {
117
- cleanup();
118
- resolve(num - 1);
119
- return;
120
- }
121
- };
99
+ const result = await selectKey({
100
+ message: "",
101
+ options: clackOptions,
102
+ });
122
103
 
123
- const cleanup = () => {
124
- process.stdin.removeListener("data", onData);
125
- restoreTty();
126
- };
104
+ if (isCancel(result)) {
105
+ return -1;
106
+ }
127
107
 
128
- process.stdin.on("data", onData);
129
- });
108
+ // Convert "1", "2", etc. back to 0-based index
109
+ return Number.parseInt(result as string, 10) - 1;
130
110
  }
131
111
 
132
112
  /**
@@ -446,15 +426,12 @@ const actionHandlers: {
446
426
  if (action.validate === "json" || action.validate === "accountAssociation") {
447
427
  rawValue = await readMultilineJson(action.message);
448
428
  } else {
449
- const { input } = await import("@inquirer/prompts");
450
- try {
451
- rawValue = await input({ message: action.message });
452
- } catch (err) {
453
- if (err instanceof Error && err.name === "ExitPromptError") {
454
- return true;
455
- }
456
- throw err;
429
+ const { isCancel, text } = await import("@clack/prompts");
430
+ const result = await text({ message: action.message });
431
+ if (isCancel(result)) {
432
+ return true;
457
433
  }
434
+ rawValue = result;
458
435
  }
459
436
 
460
437
  if (!rawValue.trim()) {
@@ -477,14 +477,23 @@ async function runAutoDetectFlow(
477
477
  }
478
478
 
479
479
  // Interactive mode - prompt for project name
480
- const { input } = await import("@inquirer/prompts");
480
+ const { isCancel, text } = await import("@clack/prompts");
481
481
 
482
482
  console.error("");
483
- const projectName = await input({
483
+ const projectNameInput = await text({
484
484
  message: "Project name:",
485
- default: defaultName,
485
+ defaultValue: defaultName,
486
486
  });
487
487
 
488
+ if (isCancel(projectNameInput)) {
489
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "Deployment cancelled", undefined, {
490
+ exitCode: 0,
491
+ reported: true,
492
+ });
493
+ }
494
+
495
+ const projectName = projectNameInput;
496
+
488
497
  const slugifiedName = slugify(projectName.trim());
489
498
  const runjackUrl = ownerUsername
490
499
  ? `https://${ownerUsername}-${slugifiedName}.runjack.xyz`
@@ -852,29 +861,28 @@ export async function createProject(
852
861
 
853
862
  // Prompt user
854
863
  reporter.stop();
855
- const { input, select } = await import("@inquirer/prompts");
864
+ const { isCancel, select, text } = await import("@clack/prompts");
856
865
  console.error("");
857
866
  console.error(` ${optionalSecret.description}`);
858
867
  if (optionalSecret.setupUrl) {
859
868
  console.error(` Setup: ${optionalSecret.setupUrl}`);
860
869
  }
861
870
  console.error("");
862
- console.error(" Esc to skip\n");
863
871
 
864
872
  const choice = await select({
865
873
  message: `Add ${optionalSecret.name}?`,
866
- choices: [
867
- { name: "1. Yes", value: "yes" },
868
- { name: "2. Skip", value: "skip" },
874
+ options: [
875
+ { label: "Yes", value: "yes" },
876
+ { label: "Skip", value: "skip" },
869
877
  ],
870
878
  });
871
879
 
872
- if (choice === "yes") {
873
- const value = await input({
880
+ if (!isCancel(choice) && choice === "yes") {
881
+ const value = await text({
874
882
  message: `Enter ${optionalSecret.name}:`,
875
883
  });
876
884
 
877
- if (value.trim()) {
885
+ if (!isCancel(value) && value.trim()) {
878
886
  secretsToUse[optionalSecret.name] = value.trim();
879
887
  // Save to global secrets for reuse
880
888
  await saveSecrets([
@@ -1,4 +1,4 @@
1
- import { input, select } from "@inquirer/prompts";
1
+ import { isCancel, select, text } from "@clack/prompts";
2
2
  import type { DetectedSecret } from "./env-parser.ts";
3
3
  import { info, success, warn } from "./output.ts";
4
4
  import { getSavedSecrets, getSecretsPath, maskSecret, saveSecrets } from "./secrets.ts";
@@ -25,14 +25,14 @@ export async function promptSaveSecrets(detected: DetectedSecret[]): Promise<voi
25
25
 
26
26
  const action = await select({
27
27
  message: "What would you like to do?",
28
- choices: [
29
- { value: "save", name: "Save to jack for future projects" },
30
- { value: "paste", name: "Paste additional secrets" },
31
- { value: "skip", name: "Skip for now" },
28
+ options: [
29
+ { value: "save", label: "Save to jack for future projects" },
30
+ { value: "paste", label: "Paste additional secrets" },
31
+ { value: "skip", label: "Skip for now" },
32
32
  ],
33
33
  });
34
34
 
35
- if (action === "skip") {
35
+ if (isCancel(action) || action === "skip") {
36
36
  return;
37
37
  }
38
38
 
@@ -65,18 +65,22 @@ async function promptAdditionalSecrets(): Promise<
65
65
  const secrets: Array<{ key: string; value: string; source: string }> = [];
66
66
 
67
67
  while (true) {
68
- const key = await input({
68
+ const key = await text({
69
69
  message: "Enter secret name (or press enter to finish):",
70
70
  });
71
71
 
72
- if (!key.trim()) {
72
+ if (isCancel(key) || !key.trim()) {
73
73
  break;
74
74
  }
75
75
 
76
- const value = await input({
76
+ const value = await text({
77
77
  message: `Enter value for ${key}:`,
78
78
  });
79
79
 
80
+ if (isCancel(value)) {
81
+ break;
82
+ }
83
+
80
84
  if (value.trim()) {
81
85
  secrets.push({ key: key.trim(), value: value.trim(), source: "manual" });
82
86
  }
@@ -107,20 +111,19 @@ export async function promptUseSecrets(): Promise<Record<string, string> | null>
107
111
  }
108
112
  console.error("");
109
113
 
110
- console.error(" Esc to skip\n");
111
114
  const action = await select({
112
115
  message: "Use them for this project?",
113
- choices: [
114
- { name: "1. Yes", value: "yes" },
115
- { name: "2. No", value: "no" },
116
+ options: [
117
+ { label: "Yes", value: "yes" },
118
+ { label: "No", value: "no" },
116
119
  ],
117
120
  });
118
121
 
119
- if (action === "yes") {
120
- return saved;
122
+ if (isCancel(action) || action !== "yes") {
123
+ return null;
121
124
  }
122
125
 
123
- return null;
126
+ return saved;
124
127
  }
125
128
 
126
129
  /**
@@ -151,14 +154,13 @@ export async function promptUseSecretsFromList(
151
154
  return false;
152
155
  }
153
156
 
154
- console.error(" Esc to skip\n");
155
157
  const action = await select({
156
158
  message: "Use saved secrets for this project?",
157
- choices: [
158
- { name: "1. Yes", value: "yes" },
159
- { name: "2. No", value: "no" },
159
+ options: [
160
+ { label: "Yes", value: "yes" },
161
+ { label: "No", value: "no" },
160
162
  ],
161
163
  });
162
164
 
163
- return action === "yes";
165
+ return !isCancel(action) && action === "yes";
164
166
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Database creation logic for jack services db create
3
+ *
4
+ * Handles both managed (control plane) and BYO (wrangler d1 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 { addD1Binding, getExistingD1Bindings } from "../wrangler-config.ts";
13
+
14
+ export interface CreateDatabaseOptions {
15
+ name?: string;
16
+ interactive?: boolean; // Whether to prompt for deploy
17
+ }
18
+
19
+ export interface CreateDatabaseResult {
20
+ databaseName: string;
21
+ databaseId: string;
22
+ bindingName: string;
23
+ created: boolean; // false if reused existing
24
+ }
25
+
26
+ /**
27
+ * Convert a database name to SCREAMING_SNAKE_CASE for the binding name.
28
+ * Special case: first database in a project gets "DB" as the binding.
29
+ */
30
+ function toBindingName(dbName: string, isFirst: boolean): string {
31
+ if (isFirst) {
32
+ return "DB";
33
+ }
34
+ // Convert kebab-case/snake_case to SCREAMING_SNAKE_CASE
35
+ return dbName
36
+ .replace(/-/g, "_")
37
+ .replace(/[^a-zA-Z0-9_]/g, "")
38
+ .toUpperCase();
39
+ }
40
+
41
+ /**
42
+ * Generate a unique database name for a project.
43
+ * First DB: {project}-db
44
+ * Subsequent DBs: {project}-db-{n}
45
+ */
46
+ function generateDatabaseName(projectName: string, existingCount: number): string {
47
+ if (existingCount === 0) {
48
+ return `${projectName}-db`;
49
+ }
50
+ return `${projectName}-db-${existingCount + 1}`;
51
+ }
52
+
53
+ interface ExistingDatabase {
54
+ uuid: string;
55
+ name: string;
56
+ }
57
+
58
+ /**
59
+ * List all D1 databases in the Cloudflare account via wrangler
60
+ */
61
+ async function listDatabasesViaWrangler(): Promise<ExistingDatabase[]> {
62
+ const result = await $`wrangler d1 list --json`.nothrow().quiet();
63
+
64
+ if (result.exitCode !== 0) {
65
+ // If wrangler fails, return empty list (might not be logged in)
66
+ return [];
67
+ }
68
+
69
+ try {
70
+ const output = result.stdout.toString().trim();
71
+ const data = JSON.parse(output);
72
+ // wrangler d1 list --json returns array: [{ "uuid": "...", "name": "...", ... }]
73
+ return Array.isArray(data) ? data : [];
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Find an existing D1 database by name
81
+ */
82
+ async function findExistingDatabase(dbName: string): Promise<ExistingDatabase | null> {
83
+ const databases = await listDatabasesViaWrangler();
84
+ return databases.find((db) => db.name === dbName) ?? null;
85
+ }
86
+
87
+ /**
88
+ * Create a D1 database via wrangler (for BYO mode)
89
+ */
90
+ async function createDatabaseViaWrangler(
91
+ dbName: string,
92
+ ): Promise<{ id: string; created: boolean }> {
93
+ // Check if database already exists
94
+ const existing = await findExistingDatabase(dbName);
95
+ if (existing) {
96
+ return { id: existing.uuid, created: false };
97
+ }
98
+
99
+ const result = await $`wrangler d1 create ${dbName} --json`.nothrow().quiet();
100
+
101
+ if (result.exitCode !== 0) {
102
+ const stderr = result.stderr.toString().trim();
103
+ throw new Error(stderr || `Failed to create database ${dbName}`);
104
+ }
105
+
106
+ try {
107
+ const output = result.stdout.toString().trim();
108
+ const data = JSON.parse(output);
109
+ // wrangler d1 create --json returns: { "uuid": "...", "name": "..." }
110
+ return { id: data.uuid || "", created: true };
111
+ } catch {
112
+ throw new Error("Failed to parse wrangler d1 create output");
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Create a D1 database for the current project.
118
+ *
119
+ * For managed projects: calls control plane POST /v1/projects/:id/resources/d1
120
+ * For BYO projects: uses wrangler d1 create
121
+ *
122
+ * In both cases, updates wrangler.jsonc with the new binding.
123
+ */
124
+ export async function createDatabase(
125
+ projectDir: string,
126
+ options: CreateDatabaseOptions = {},
127
+ ): Promise<CreateDatabaseResult> {
128
+ // Read project link to determine deploy mode
129
+ const link = await readProjectLink(projectDir);
130
+ if (!link) {
131
+ throw new Error("Not in a jack project. Run 'jack new' to create a project.");
132
+ }
133
+
134
+ // Get project name from wrangler config
135
+ const projectName = await getProjectNameFromDir(projectDir);
136
+
137
+ // Get existing D1 bindings to determine naming
138
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
139
+ const existingBindings = await getExistingD1Bindings(wranglerPath);
140
+ const existingCount = existingBindings.length;
141
+
142
+ // Determine database name
143
+ const databaseName = options.name ?? generateDatabaseName(projectName, existingCount);
144
+
145
+ // Determine binding name
146
+ const isFirst = existingCount === 0;
147
+ const bindingName = toBindingName(databaseName, isFirst);
148
+
149
+ // Check if binding name already exists
150
+ const bindingExists = existingBindings.some((b) => b.binding === bindingName);
151
+ if (bindingExists) {
152
+ throw new Error(`Binding "${bindingName}" already exists. Choose a different database name.`);
153
+ }
154
+
155
+ let databaseId: string;
156
+ let created = true;
157
+
158
+ if (link.deploy_mode === "managed") {
159
+ // Managed mode: call control plane
160
+ // Note: Control plane will reuse existing DB if name matches
161
+ const resource = await createProjectResource(link.project_id, "d1", {
162
+ name: databaseName,
163
+ bindingName,
164
+ });
165
+ databaseId = resource.provider_id;
166
+ // Control plane always creates for now; could add reuse logic there too
167
+ } else {
168
+ // BYO mode: use wrangler d1 create (checks for existing first)
169
+ const result = await createDatabaseViaWrangler(databaseName);
170
+ databaseId = result.id;
171
+ created = result.created;
172
+ }
173
+
174
+ // Update wrangler.jsonc with the new binding
175
+ await addD1Binding(wranglerPath, {
176
+ binding: bindingName,
177
+ database_name: databaseName,
178
+ database_id: databaseId,
179
+ });
180
+
181
+ return {
182
+ databaseName,
183
+ databaseId,
184
+ bindingName,
185
+ created,
186
+ };
187
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Database listing logic for jack services db list
3
+ *
4
+ * Lists D1 databases configured in wrangler.jsonc with their metadata.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { getExistingD1Bindings } from "../wrangler-config.ts";
9
+ import { getDatabaseInfo } from "./db.ts";
10
+
11
+ export interface DatabaseListEntry {
12
+ name: string;
13
+ binding: string;
14
+ id: string;
15
+ sizeBytes?: number;
16
+ numTables?: number;
17
+ }
18
+
19
+ /**
20
+ * List all D1 databases configured for a project.
21
+ *
22
+ * Reads bindings from wrangler.jsonc and fetches additional metadata
23
+ * (size, table count) via wrangler d1 info for each database.
24
+ */
25
+ export async function listDatabases(projectDir: string): Promise<DatabaseListEntry[]> {
26
+ const wranglerPath = join(projectDir, "wrangler.jsonc");
27
+
28
+ // Get existing D1 bindings from wrangler.jsonc
29
+ const bindings = await getExistingD1Bindings(wranglerPath);
30
+
31
+ if (bindings.length === 0) {
32
+ return [];
33
+ }
34
+
35
+ // Fetch detailed info for each database
36
+ const entries: DatabaseListEntry[] = [];
37
+
38
+ for (const binding of bindings) {
39
+ const entry: DatabaseListEntry = {
40
+ name: binding.database_name,
41
+ binding: binding.binding,
42
+ id: binding.database_id,
43
+ };
44
+
45
+ // Try to get additional metadata via wrangler
46
+ const info = await getDatabaseInfo(binding.database_name);
47
+ if (info) {
48
+ entry.sizeBytes = info.sizeBytes;
49
+ entry.numTables = info.numTables;
50
+ }
51
+
52
+ entries.push(entry);
53
+ }
54
+
55
+ return entries;
56
+ }