@getjack/jack 0.1.29 → 0.1.31

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/src/lib/output.ts CHANGED
@@ -162,7 +162,12 @@ export function box(title: string, lines: string[]): void {
162
162
  if (isQuietMode) {
163
163
  return; // Skip decorative boxes in quiet mode
164
164
  }
165
- const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
165
+ // Respect terminal width (leave room for box borders + indent)
166
+ const termWidth = process.stderr.columns || process.stdout.columns || 80;
167
+ const maxBoxWidth = Math.max(30, termWidth - 6); // 2 indent + 2 borders + 2 padding
168
+
169
+ const contentMaxLen = Math.max(title.length, ...lines.map((l) => l.length));
170
+ const maxLen = Math.min(contentMaxLen, maxBoxWidth - 4);
166
171
  const innerWidth = maxLen + 4;
167
172
 
168
173
  const purple = isColorEnabled ? getRandomPurple() : "";
@@ -173,10 +178,16 @@ export function box(title: string, lines: string[]): void {
173
178
  const fill = "▓".repeat(innerWidth);
174
179
  const gradient = "░".repeat(innerWidth);
175
180
 
181
+ // Truncate text if too long for box
182
+ const truncate = (text: string) =>
183
+ text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
184
+
176
185
  // Pad plain text first, then apply colors (ANSI codes break padEnd calculation)
177
- const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
178
- const padTitle = (text: string) =>
179
- ` ${bold}${text}${reset}${purple}${" ".repeat(maxLen - text.length)} `;
186
+ const pad = (text: string) => ` ${truncate(text).padEnd(maxLen)} `;
187
+ const padTitle = (text: string) => {
188
+ const t = truncate(text);
189
+ return ` ${bold}${t}${reset}${purple}${" ".repeat(maxLen - t.length)} `;
190
+ };
180
191
 
181
192
  console.error("");
182
193
  console.error(` ${purple}╔${bar}╗${reset}`);
@@ -198,7 +209,12 @@ export function celebrate(title: string, lines: string[]): void {
198
209
  if (isQuietMode) {
199
210
  return; // Skip decorative boxes in quiet mode
200
211
  }
201
- const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
212
+ // Respect terminal width (leave room for box borders + indent)
213
+ const termWidth = process.stderr.columns || process.stdout.columns || 80;
214
+ const maxBoxWidth = Math.max(30, termWidth - 6); // 2 indent + 2 borders + 2 padding
215
+
216
+ const contentMaxLen = Math.max(title.length, ...lines.map((l) => l.length));
217
+ const maxLen = Math.min(contentMaxLen, maxBoxWidth - 4);
202
218
  const innerWidth = maxLen + 4;
203
219
 
204
220
  const purple = isColorEnabled ? getRandomPurple() : "";
@@ -210,12 +226,17 @@ export function celebrate(title: string, lines: string[]): void {
210
226
  const gradient = "░".repeat(innerWidth);
211
227
  const space = " ".repeat(innerWidth);
212
228
 
229
+ // Truncate text if too long for box
230
+ const truncate = (text: string) =>
231
+ text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
232
+
213
233
  // Center text based on visual length, then apply colors
214
234
  const center = (text: string, applyBold = false) => {
215
- const left = Math.floor((innerWidth - text.length) / 2);
216
- const right = innerWidth - text.length - left;
217
- const centered = " ".repeat(left) + text + " ".repeat(right);
218
- return applyBold ? centered.replace(text, bold + text + reset + purple) : centered;
235
+ const t = truncate(text);
236
+ const left = Math.floor((innerWidth - t.length) / 2);
237
+ const right = innerWidth - t.length - left;
238
+ const centered = " ".repeat(left) + t + " ".repeat(right);
239
+ return applyBold ? centered.replace(t, bold + t + reset + purple) : centered;
219
240
  };
220
241
 
221
242
  console.error("");
package/src/lib/picker.ts CHANGED
@@ -86,7 +86,9 @@ export function requireTTY(): void {
86
86
  * Interactive project picker using @clack/core primitives
87
87
  * @param options.cloudOnly - If true, only shows cloud-only projects (for linking)
88
88
  */
89
- export async function pickProject(options?: PickProjectOptions): Promise<PickerResult | PickerCancelResult> {
89
+ export async function pickProject(
90
+ options?: PickProjectOptions,
91
+ ): Promise<PickerResult | PickerCancelResult> {
90
92
  // Fetch all projects
91
93
  let allProjects: ProjectListItem[];
92
94
  try {
@@ -47,7 +47,7 @@ import { debug, isDebug, printTimingSummary, timerEnd, timerStart } from "./debu
47
47
  import { ensureWranglerInstalled, validateModeAvailability } from "./deploy-mode.ts";
48
48
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
49
49
  import { JackError, JackErrorCode } from "./errors.ts";
50
- import { type HookOutput, runHook } from "./hooks.ts";
50
+ import { type HookOutput, promptSelect, runHook } from "./hooks.ts";
51
51
  import { loadTemplateKeywords, matchTemplateByIntent } from "./intent.ts";
52
52
  import {
53
53
  type ManagedCreateResult,
@@ -1605,13 +1605,36 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1605
1605
  // User is logged into Jack Cloud - create managed project
1606
1606
  const orphanedProjectName = await getProjectNameFromDir(projectPath);
1607
1607
 
1608
- reporter.info(`Linking "${orphanedProjectName}" to jack cloud...`);
1609
-
1610
- // Get username for URL construction
1608
+ // Get username for confirmation prompt and URL construction
1611
1609
  const { getCurrentUserProfile } = await import("./control-plane.ts");
1612
1610
  const profile = await getCurrentUserProfile();
1613
1611
  const ownerUsername = profile?.username ?? undefined;
1614
1612
 
1613
+ // Confirm before creating new project
1614
+ if (interactive) {
1615
+ reporter.info("This project isn't linked to jack cloud.");
1616
+ const choice = await promptSelect(
1617
+ ["Yes", "No"],
1618
+ `Create new project "${orphanedProjectName}" under @${ownerUsername ?? "unknown"}?`,
1619
+ );
1620
+ if (choice !== 0) {
1621
+ reporter.info("Cancelled. To link to an existing project, use: jack link <project-id>");
1622
+ return {
1623
+ projectName: orphanedProjectName,
1624
+ workerUrl: null,
1625
+ deployMode: "managed" as DeployMode,
1626
+ };
1627
+ }
1628
+ } else {
1629
+ throw new JackError(
1630
+ JackErrorCode.PROJECT_NOT_FOUND,
1631
+ "Project not linked to jack cloud (non-interactive mode)",
1632
+ "Run interactively or use: jack link <project-id>",
1633
+ );
1634
+ }
1635
+
1636
+ reporter.info(`Linking "${orphanedProjectName}" to jack cloud...`);
1637
+
1615
1638
  // Create managed project on jack cloud
1616
1639
  const remoteResult = await createManagedProjectRemote(orphanedProjectName, reporter, {
1617
1640
  usePrebuilt: false,
@@ -1716,6 +1739,17 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1716
1739
  );
1717
1740
  }
1718
1741
 
1742
+ // Show current identity for visibility (managed mode only, not dry run)
1743
+ if (!dryRun) {
1744
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1745
+ const profile = await getCurrentUserProfile();
1746
+ if (profile?.username) {
1747
+ reporter.info(`Deploying as @${profile.username}...`);
1748
+ } else if (profile?.email) {
1749
+ reporter.info(`Deploying as ${profile.email}...`);
1750
+ }
1751
+ }
1752
+
1719
1753
  // Dry run: build for validation then stop before actual deployment
1720
1754
  // (deployToManagedProject handles its own build, so only build here for dry-run)
1721
1755
  if (dryRun) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cron schedule creation logic for jack services cron create
3
+ *
4
+ * Validates cron expression, checks minimum interval, and calls control plane API.
5
+ * Only supported for managed (Jack Cloud) projects.
6
+ */
7
+
8
+ import { createCronSchedule as createCronScheduleApi } from "../control-plane.ts";
9
+ import { readProjectLink } from "../project-link.ts";
10
+ import { checkMinimumInterval, validateCronExpression } from "./cron-utils.ts";
11
+
12
+ // Minimum interval between cron runs (in minutes)
13
+ const MIN_INTERVAL_MINUTES = 15;
14
+
15
+ export interface CreateCronScheduleOptions {
16
+ interactive?: boolean;
17
+ }
18
+
19
+ export interface CreateCronScheduleResult {
20
+ id: string;
21
+ expression: string;
22
+ description: string;
23
+ nextRunAt: string;
24
+ created: boolean;
25
+ }
26
+
27
+ /**
28
+ * Create a cron schedule for the current project.
29
+ *
30
+ * For managed projects: calls control plane POST /v1/projects/:id/crons
31
+ * For BYO projects: throws error (not supported)
32
+ */
33
+ export async function createCronSchedule(
34
+ projectDir: string,
35
+ expression: string,
36
+ options: CreateCronScheduleOptions = {},
37
+ ): Promise<CreateCronScheduleResult> {
38
+ // Validate expression
39
+ const validation = validateCronExpression(expression);
40
+ if (!validation.valid) {
41
+ throw new Error(`Invalid cron expression: ${validation.error}`);
42
+ }
43
+
44
+ const normalizedExpression = validation.normalized!;
45
+
46
+ // Check minimum interval (15 minutes)
47
+ if (!checkMinimumInterval(normalizedExpression, MIN_INTERVAL_MINUTES)) {
48
+ throw new Error(
49
+ `Cron schedules must run at least ${MIN_INTERVAL_MINUTES} minutes apart. ` +
50
+ "This limit helps ensure reliable execution.",
51
+ );
52
+ }
53
+
54
+ // Must be managed mode
55
+ const link = await readProjectLink(projectDir);
56
+ if (!link || link.deploy_mode !== "managed") {
57
+ throw new Error(
58
+ "Cron schedules are only supported for Jack Cloud (managed) projects.\n" +
59
+ "BYO projects can use native Cloudflare cron triggers in wrangler.toml.",
60
+ );
61
+ }
62
+
63
+ // Create via control plane
64
+ const result = await createCronScheduleApi(link.project_id, normalizedExpression);
65
+
66
+ return {
67
+ id: result.id,
68
+ expression: result.expression,
69
+ description: result.description,
70
+ nextRunAt: result.next_run_at,
71
+ created: true,
72
+ };
73
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Cron schedule deletion logic for jack services cron delete
3
+ *
4
+ * Finds schedule by expression and deletes via control plane API.
5
+ * Only supported for managed (Jack Cloud) projects.
6
+ */
7
+
8
+ import {
9
+ deleteCronSchedule as deleteCronScheduleApi,
10
+ listCronSchedules as listCronSchedulesApi,
11
+ } from "../control-plane.ts";
12
+ import { readProjectLink } from "../project-link.ts";
13
+ import { normalizeCronExpression } from "./cron-utils.ts";
14
+
15
+ export interface DeleteCronScheduleOptions {
16
+ interactive?: boolean;
17
+ }
18
+
19
+ export interface DeleteCronScheduleResult {
20
+ expression: string;
21
+ deleted: boolean;
22
+ }
23
+
24
+ /**
25
+ * Delete a cron schedule by its expression.
26
+ *
27
+ * For managed projects: finds the schedule by expression and calls DELETE API
28
+ * For BYO projects: throws error (not supported)
29
+ */
30
+ export async function deleteCronSchedule(
31
+ projectDir: string,
32
+ expression: string,
33
+ options: DeleteCronScheduleOptions = {},
34
+ ): Promise<DeleteCronScheduleResult> {
35
+ const normalizedExpression = normalizeCronExpression(expression);
36
+
37
+ // Must be managed mode
38
+ const link = await readProjectLink(projectDir);
39
+ if (!link || link.deploy_mode !== "managed") {
40
+ throw new Error(
41
+ "Cron schedules are only supported for Jack Cloud (managed) projects.\n" +
42
+ "BYO projects can use native Cloudflare cron triggers in wrangler.toml.",
43
+ );
44
+ }
45
+
46
+ // Find the schedule by expression
47
+ const schedules = await listCronSchedulesApi(link.project_id);
48
+ const schedule = schedules.find(
49
+ (s) => normalizeCronExpression(s.expression) === normalizedExpression,
50
+ );
51
+
52
+ if (!schedule) {
53
+ throw new Error(
54
+ `No cron schedule found with expression "${normalizedExpression}".\n` +
55
+ "Use 'jack services cron list' to see all schedules.",
56
+ );
57
+ }
58
+
59
+ // Delete via control plane
60
+ await deleteCronScheduleApi(link.project_id, schedule.id);
61
+
62
+ return {
63
+ expression: normalizedExpression,
64
+ deleted: true,
65
+ };
66
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Cron schedule listing logic for jack services cron list
3
+ *
4
+ * Fetches cron schedules from control plane API.
5
+ * Only supported for managed (Jack Cloud) projects.
6
+ */
7
+
8
+ import {
9
+ type CronScheduleInfo,
10
+ listCronSchedules as listCronSchedulesApi,
11
+ } from "../control-plane.ts";
12
+ import { readProjectLink } from "../project-link.ts";
13
+
14
+ export interface CronScheduleListEntry {
15
+ id: string;
16
+ expression: string;
17
+ description: string;
18
+ enabled: boolean;
19
+ nextRunAt: string;
20
+ lastRunAt: string | null;
21
+ lastRunStatus: string | null;
22
+ lastRunDurationMs: number | null;
23
+ consecutiveFailures: number;
24
+ createdAt: string;
25
+ }
26
+
27
+ /**
28
+ * List all cron schedules for the current project.
29
+ *
30
+ * For managed projects: calls control plane GET /v1/projects/:id/crons
31
+ * For BYO projects: throws error (not supported)
32
+ */
33
+ export async function listCronSchedules(projectDir: string): Promise<CronScheduleListEntry[]> {
34
+ // Must be managed mode
35
+ const link = await readProjectLink(projectDir);
36
+ if (!link || link.deploy_mode !== "managed") {
37
+ throw new Error(
38
+ "Cron schedules are only supported for Jack Cloud (managed) projects.\n" +
39
+ "BYO projects can use native Cloudflare cron triggers in wrangler.toml.",
40
+ );
41
+ }
42
+
43
+ // Fetch from control plane
44
+ const schedules = await listCronSchedulesApi(link.project_id);
45
+
46
+ // Map to our format
47
+ return schedules.map((s: CronScheduleInfo) => ({
48
+ id: s.id,
49
+ expression: s.expression,
50
+ description: s.description,
51
+ enabled: s.enabled,
52
+ nextRunAt: s.next_run_at,
53
+ lastRunAt: s.last_run_at,
54
+ lastRunStatus: s.last_run_status,
55
+ lastRunDurationMs: s.last_run_duration_ms,
56
+ consecutiveFailures: s.consecutive_failures,
57
+ createdAt: s.created_at,
58
+ }));
59
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cron schedule testing logic for jack services cron test
3
+ *
4
+ * Validates expression, shows human-readable description and next times.
5
+ * Optionally triggers the schedule on production (managed projects only).
6
+ */
7
+
8
+ import { triggerCronSchedule as triggerCronScheduleApi } from "../control-plane.ts";
9
+ import { readProjectLink } from "../project-link.ts";
10
+ import {
11
+ describeCronExpression,
12
+ getNextScheduledTimes,
13
+ validateCronExpression,
14
+ } from "./cron-utils.ts";
15
+
16
+ export interface CronTestOptions {
17
+ triggerProduction?: boolean;
18
+ interactive?: boolean;
19
+ }
20
+
21
+ export interface CronTestResult {
22
+ valid: boolean;
23
+ error?: string;
24
+ expression?: string;
25
+ description?: string;
26
+ nextTimes?: Date[];
27
+ triggerResult?: {
28
+ triggered: boolean;
29
+ status: string;
30
+ durationMs: number;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Test a cron expression: validate, describe, and show next run times.
36
+ * Optionally trigger the schedule handler on production.
37
+ */
38
+ export async function testCronExpression(
39
+ projectDir: string,
40
+ expression: string,
41
+ options: CronTestOptions = {},
42
+ ): Promise<CronTestResult> {
43
+ // Validate expression
44
+ const validation = validateCronExpression(expression);
45
+ if (!validation.valid) {
46
+ return {
47
+ valid: false,
48
+ error: validation.error,
49
+ };
50
+ }
51
+
52
+ const normalizedExpression = validation.normalized!;
53
+
54
+ // Get description and next times
55
+ const description = describeCronExpression(normalizedExpression);
56
+ const nextTimes = getNextScheduledTimes(normalizedExpression, 5);
57
+
58
+ const result: CronTestResult = {
59
+ valid: true,
60
+ expression: normalizedExpression,
61
+ description,
62
+ nextTimes,
63
+ };
64
+
65
+ // Optionally trigger production
66
+ if (options.triggerProduction) {
67
+ const link = await readProjectLink(projectDir);
68
+ if (!link || link.deploy_mode !== "managed") {
69
+ throw new Error("Production trigger is only available for Jack Cloud (managed) projects.");
70
+ }
71
+
72
+ const triggerResponse = await triggerCronScheduleApi(link.project_id, normalizedExpression);
73
+ result.triggerResult = {
74
+ triggered: triggerResponse.triggered,
75
+ status: triggerResponse.status,
76
+ durationMs: triggerResponse.duration_ms,
77
+ };
78
+ }
79
+
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Common cron expression patterns for help output.
85
+ */
86
+ export const COMMON_CRON_PATTERNS = [
87
+ { expression: "*/15 * * * *", description: "Every 15 minutes" },
88
+ { expression: "0 * * * *", description: "Every hour" },
89
+ { expression: "0 0 * * *", description: "Daily at midnight" },
90
+ { expression: "0 9 * * *", description: "Daily at 9am" },
91
+ { expression: "0 9 * * 1", description: "Every Monday at 9am" },
92
+ { expression: "0 0 1 * *", description: "First day of every month" },
93
+ ];
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Cron expression utilities for validation, parsing, and human-readable descriptions.
3
+ */
4
+
5
+ import parser from "cron-parser";
6
+ import cronstrue from "cronstrue";
7
+
8
+ /**
9
+ * Normalize a cron expression by collapsing whitespace.
10
+ * "0 * * * *" -> "0 * * * *"
11
+ */
12
+ export function normalizeCronExpression(expression: string): string {
13
+ return expression.trim().replace(/\s+/g, " ");
14
+ }
15
+
16
+ /**
17
+ * Validate a cron expression.
18
+ * Returns normalized expression if valid, error message if invalid.
19
+ */
20
+ export function validateCronExpression(expression: string): {
21
+ valid: boolean;
22
+ error?: string;
23
+ normalized?: string;
24
+ } {
25
+ try {
26
+ const normalized = normalizeCronExpression(expression);
27
+ parser.parseExpression(normalized);
28
+ return { valid: true, normalized };
29
+ } catch (e) {
30
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if a cron expression has at least the minimum interval between runs.
36
+ * Returns true if the interval is >= minMinutes.
37
+ */
38
+ export function checkMinimumInterval(expression: string, minMinutes: number): boolean {
39
+ try {
40
+ const interval = parser.parseExpression(expression);
41
+ const first = interval.next().toDate();
42
+ const second = interval.next().toDate();
43
+ return (second.getTime() - first.getTime()) / 1000 / 60 >= minMinutes;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Get the next N scheduled times for a cron expression.
51
+ */
52
+ export function getNextScheduledTimes(expression: string, count: number): Date[] {
53
+ const interval = parser.parseExpression(expression);
54
+ const times: Date[] = [];
55
+ for (let i = 0; i < count; i++) {
56
+ times.push(interval.next().toDate());
57
+ }
58
+ return times;
59
+ }
60
+
61
+ /**
62
+ * Get a human-readable description of a cron expression.
63
+ * e.g., "0 * * * *" -> "At minute 0"
64
+ */
65
+ export function describeCronExpression(expression: string): string {
66
+ try {
67
+ return cronstrue.toString(expression);
68
+ } catch {
69
+ return expression;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Compute the next run time for a cron expression as ISO string.
75
+ */
76
+ export function computeNextRun(expression: string): string {
77
+ return parser.parseExpression(expression).next().toDate().toISOString();
78
+ }
@@ -15,14 +15,18 @@ import { JackError, JackErrorCode } from "../errors.ts";
15
15
 
16
16
  export type DomainStatus =
17
17
  | "claimed"
18
+ | "unassigned"
18
19
  | "pending"
20
+ | "pending_dns"
19
21
  | "pending_owner"
20
22
  | "pending_ssl"
21
23
  | "active"
22
24
  | "blocked"
23
25
  | "moved"
24
26
  | "failed"
25
- | "deleting";
27
+ | "deleting"
28
+ | "expired"
29
+ | "deleted";
26
30
 
27
31
  export interface DomainVerification {
28
32
  type: "cname";
@@ -36,6 +40,22 @@ export interface DomainOwnershipVerification {
36
40
  value: string;
37
41
  }
38
42
 
43
+ export interface DomainDns {
44
+ verified: boolean;
45
+ checked_at: string | null;
46
+ current_target: string | null;
47
+ expected_target: string | null;
48
+ error: string | null;
49
+ }
50
+
51
+ export interface DomainNextStep {
52
+ action: string;
53
+ record_type?: string;
54
+ record_name?: string;
55
+ record_value?: string;
56
+ message?: string;
57
+ }
58
+
39
59
  export interface DomainInfo {
40
60
  id: string;
41
61
  hostname: string;
@@ -45,6 +65,8 @@ export interface DomainInfo {
45
65
  project_slug: string | null;
46
66
  verification?: DomainVerification;
47
67
  ownership_verification?: DomainOwnershipVerification;
68
+ dns?: DomainDns;
69
+ next_step?: DomainNextStep;
48
70
  created_at: string;
49
71
  }
50
72
 
@@ -96,16 +118,20 @@ interface ListDomainsApiResponse {
96
118
  }
97
119
 
98
120
  interface ConnectDomainApiResponse {
99
- id: string;
100
- hostname: string;
101
- status: string;
121
+ domain: {
122
+ id: string;
123
+ hostname: string;
124
+ status: string;
125
+ };
102
126
  }
103
127
 
104
128
  interface AssignDomainApiResponse {
105
- id: string;
106
- hostname: string;
107
- status: string;
108
- ssl_status: string | null;
129
+ domain: {
130
+ id: string;
131
+ hostname: string;
132
+ status: string;
133
+ ssl_status: string | null;
134
+ };
109
135
  verification?: DomainVerification;
110
136
  ownership_verification?: DomainOwnershipVerification;
111
137
  }
@@ -215,9 +241,9 @@ export async function connectDomain(hostname: string): Promise<ConnectDomainResu
215
241
 
216
242
  const data = (await response.json()) as ConnectDomainApiResponse;
217
243
  return {
218
- id: data.id,
219
- hostname: data.hostname,
220
- status: data.status as DomainStatus,
244
+ id: data.domain.id,
245
+ hostname: data.domain.hostname,
246
+ status: data.domain.status as DomainStatus,
221
247
  };
222
248
  }
223
249
 
@@ -282,10 +308,10 @@ export async function assignDomain(
282
308
 
283
309
  const data = (await response.json()) as AssignDomainApiResponse;
284
310
  return {
285
- id: data.id,
286
- hostname: data.hostname,
287
- status: data.status as DomainStatus,
288
- ssl_status: data.ssl_status,
311
+ id: data.domain.id,
312
+ hostname: data.domain.hostname,
313
+ status: data.domain.status as DomainStatus,
314
+ ssl_status: data.domain.ssl_status,
289
315
  project_id: project.id,
290
316
  project_slug: projectSlug,
291
317
  verification: data.verification,
@@ -334,10 +360,13 @@ export async function unassignDomain(hostname: string): Promise<UnassignDomainRe
334
360
  );
335
361
  }
336
362
 
363
+ const data = (await response.json()) as {
364
+ domain: { id: string; hostname: string; status: string };
365
+ };
337
366
  return {
338
- id: domain.id,
339
- hostname: domain.hostname,
340
- status: "claimed" as DomainStatus,
367
+ id: data.domain.id,
368
+ hostname: data.domain.hostname,
369
+ status: data.domain.status as DomainStatus,
341
370
  };
342
371
  }
343
372
 
@@ -377,3 +406,45 @@ export async function disconnectDomain(hostname: string): Promise<DisconnectDoma
377
406
  hostname: domain.hostname,
378
407
  };
379
408
  }
409
+
410
+ /**
411
+ * Verify DNS configuration for a domain.
412
+ *
413
+ * @throws JackError with RESOURCE_NOT_FOUND if domain not found
414
+ */
415
+ export interface VerifyDomainResult {
416
+ domain: DomainInfo;
417
+ dns_check?: {
418
+ verified: boolean;
419
+ target: string | null;
420
+ error: string | null;
421
+ };
422
+ }
423
+
424
+ export async function verifyDomain(hostname: string): Promise<VerifyDomainResult> {
425
+ const domain = await getDomainByHostname(hostname);
426
+ if (!domain) {
427
+ throw new JackError(
428
+ JackErrorCode.PROJECT_NOT_FOUND,
429
+ `Domain not found: ${hostname}`,
430
+ "Run 'jack domain' to see all domains",
431
+ { exitCode: 1 },
432
+ );
433
+ }
434
+
435
+ const response = await authFetch(`${getControlApiUrl()}/v1/domains/${domain.id}/verify`, {
436
+ method: "POST",
437
+ });
438
+
439
+ if (!response.ok) {
440
+ const err = (await response
441
+ .json()
442
+ .catch(() => ({ message: "Unknown error" }))) as ApiErrorResponse;
443
+ throw new JackError(
444
+ JackErrorCode.INTERNAL_ERROR,
445
+ err.message || `Failed to verify domain: ${response.status}`,
446
+ );
447
+ }
448
+
449
+ return (await response.json()) as VerifyDomainResult;
450
+ }