@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/package.json +4 -5
- package/src/commands/domain.ts +148 -17
- package/src/commands/domains.ts +28 -2
- package/src/commands/services.ts +300 -1
- package/src/commands/skills.ts +58 -1
- package/src/lib/auth/login-flow.ts +34 -0
- package/src/lib/control-plane.ts +156 -0
- package/src/lib/mcp-config.ts +26 -4
- package/src/lib/output.ts +30 -9
- package/src/lib/picker.ts +3 -1
- package/src/lib/project-operations.ts +38 -4
- package/src/lib/services/cron-create.ts +73 -0
- package/src/lib/services/cron-delete.ts +66 -0
- package/src/lib/services/cron-list.ts +59 -0
- package/src/lib/services/cron-test.ts +93 -0
- package/src/lib/services/cron-utils.ts +78 -0
- package/src/lib/services/domain-operations.ts +89 -18
- package/src/mcp/server.ts +20 -0
- package/src/mcp/tools/index.ts +279 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
domain: {
|
|
122
|
+
id: string;
|
|
123
|
+
hostname: string;
|
|
124
|
+
status: string;
|
|
125
|
+
};
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
interface AssignDomainApiResponse {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
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
|
+
}
|