@switchboard.spot/cli 0.2.1 → 0.2.3
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/README.md +18 -7
- package/bin/switchboard.js +5 -1
- package/lib/commands/auth.js +147 -38
- package/lib/commands/billing.js +43 -0
- package/lib/commands/docs.js +176 -0
- package/lib/commands/doctor.js +108 -42
- package/lib/commands/endUsers.js +1 -1
- package/lib/commands/init.js +8 -6
- package/lib/commands/launch.js +213 -0
- package/lib/commands/projects.js +93 -5
- package/lib/commands/setup.js +282 -7
- package/lib/commands/verify.js +3 -3
- package/lib/config.js +12 -9
- package/lib/docsClient.js +157 -0
- package/lib/mcpServer.js +193 -0
- package/lib/output.js +65 -9
- package/lib/verify/index.js +32 -4
- package/package.json +4 -2
package/lib/commands/doctor.js
CHANGED
|
@@ -21,54 +21,120 @@ export function registerDoctorCommand(program) {
|
|
|
21
21
|
.description("Check health, auth, project, catalog, and gateway readiness")
|
|
22
22
|
.action(async (_opts, cmd) => {
|
|
23
23
|
const flags = globalFlags(cmd);
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const report = await runDoctorChecks();
|
|
25
|
+
emit(report, flags);
|
|
26
|
+
if (!report.ok) process.exit(1);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runDoctorChecks({
|
|
32
|
+
config = resolveConfig(),
|
|
33
|
+
request = accountRequest,
|
|
34
|
+
health = healthCheck,
|
|
35
|
+
fetchImpl = fetch,
|
|
36
|
+
resolveAccount = resolveAccountConfig,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const accountConfig = await resolveAccount(config);
|
|
39
|
+
|
|
40
|
+
const checks = [];
|
|
26
41
|
|
|
27
|
-
|
|
42
|
+
checks.push(
|
|
43
|
+
await check("health", async () => {
|
|
44
|
+
const result = await health(config);
|
|
45
|
+
return { status: result.status, data: result.data };
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
49
|
+
checks.push(
|
|
50
|
+
await check("catalog", async () => {
|
|
51
|
+
const res = await fetchImpl(`${gatewayApiUrl(config)}/catalog/models`);
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
const status = res.status;
|
|
54
|
+
if (!res.ok) throw new Error(`HTTP ${status}`);
|
|
55
|
+
return { status, count: Array.isArray(data?.data) ? data.data.length : 0 };
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
35
58
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
if (accountConfig.accountToken) {
|
|
60
|
+
checks.push(
|
|
61
|
+
await check("account", async () => {
|
|
62
|
+
const { status, data } = await request("GET", "/me", {
|
|
63
|
+
config: accountConfig,
|
|
64
|
+
json: true,
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
status,
|
|
68
|
+
email: data?.user?.email || null,
|
|
69
|
+
tokenSource: accountConfig.accountTokenSource,
|
|
70
|
+
};
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.projectId) {
|
|
78
|
+
checks.push({ name: "project", ok: true, projectId: config.projectId });
|
|
79
|
+
} else {
|
|
80
|
+
checks.push({ name: "project", ok: false, error: "No project selected" });
|
|
81
|
+
}
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
status,
|
|
55
|
-
email: data?.user?.email || null,
|
|
56
|
-
tokenSource: accountConfig.accountTokenSource,
|
|
57
|
-
};
|
|
58
|
-
}),
|
|
83
|
+
if (config.virtualMicroserviceUrl) {
|
|
84
|
+
checks.push(
|
|
85
|
+
await check("client_gateway_config", async () => {
|
|
86
|
+
const res = await fetchImpl(
|
|
87
|
+
new URL("client/config", ensureTrailingSlash(config.virtualMicroserviceUrl)).href,
|
|
88
|
+
{
|
|
89
|
+
headers: { Accept: "application/json" },
|
|
90
|
+
},
|
|
59
91
|
);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
const data = await readJson(res);
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
|
|
95
|
+
}
|
|
63
96
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} else {
|
|
67
|
-
checks.push({ name: "project", ok: false, error: "No project selected" });
|
|
68
|
-
}
|
|
97
|
+
const productionSafety = data?.production_safety ?? null;
|
|
98
|
+
const productionBlocked = productionSafety?.status === "blocked";
|
|
69
99
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
100
|
+
return {
|
|
101
|
+
status: res.status,
|
|
102
|
+
clientUrl: config.virtualMicroserviceUrl,
|
|
103
|
+
browserChallengeProvider: data?.browser_challenge?.provider ?? null,
|
|
104
|
+
production_safety: productionSafety,
|
|
105
|
+
warning: productionBlocked,
|
|
106
|
+
warningCode: productionBlocked ? "production_safety_blocked" : null,
|
|
107
|
+
warningMessage: productionBlocked
|
|
108
|
+
? "Sandbox Client Gateway config is reachable, but production launch blockers remain."
|
|
109
|
+
: null,
|
|
110
|
+
};
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
checks.push({
|
|
115
|
+
name: "client_gateway_config",
|
|
116
|
+
ok: false,
|
|
117
|
+
error: "No SWITCHBOARD_CLIENT_URL or VITE_SWITCHBOARD_CLIENT_URL configured",
|
|
73
118
|
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
object: "doctor_report",
|
|
123
|
+
ok: checks.every((item) => item.ok),
|
|
124
|
+
baseUrl: config.baseUrl,
|
|
125
|
+
checks,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ensureTrailingSlash(value) {
|
|
130
|
+
return String(value).endsWith("/") ? String(value) : `${value}/`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readJson(response) {
|
|
134
|
+
const text = await response.text();
|
|
135
|
+
try {
|
|
136
|
+
return text ? JSON.parse(text) : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return { raw: text };
|
|
139
|
+
}
|
|
74
140
|
}
|
package/lib/commands/endUsers.js
CHANGED
|
@@ -8,7 +8,7 @@ import { emit, globalFlags, printList } from "../output.js";
|
|
|
8
8
|
export function registerEndUsersCommands(program) {
|
|
9
9
|
const endUsers = program
|
|
10
10
|
.command("end-users")
|
|
11
|
-
.description("Anonymous
|
|
11
|
+
.description("Anonymous Client Gateway end users");
|
|
12
12
|
|
|
13
13
|
endUsers
|
|
14
14
|
.command("list")
|
package/lib/commands/init.js
CHANGED
|
@@ -8,9 +8,9 @@ import { resolveConfig, gatewayApiUrl } from "../config.js";
|
|
|
8
8
|
import { emit } from "../output.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Builds default frontend environment placeholders for
|
|
11
|
+
* Builds default frontend environment placeholders for Client Gateway apps.
|
|
12
12
|
*
|
|
13
|
-
* The generated
|
|
13
|
+
* The generated Client Gateway URL is the only value a browser app needs by default.
|
|
14
14
|
*/
|
|
15
15
|
function envBlock(clientUrl) {
|
|
16
16
|
return `SWITCHBOARD_CLIENT_URL=${clientUrl}
|
|
@@ -32,11 +32,13 @@ export function agentManifest(config) {
|
|
|
32
32
|
account_api: `${config.baseUrl.replace(/\/$/, "")}/v1/account`,
|
|
33
33
|
env: envBlock(clientUrl),
|
|
34
34
|
checklist: [
|
|
35
|
-
"switchboard setup --
|
|
36
|
-
"export VITE_SWITCHBOARD_CLIENT_URL=<client_url from
|
|
35
|
+
"switchboard setup project --origin <origin> --json",
|
|
36
|
+
"export VITE_SWITCHBOARD_CLIENT_URL=<client_url from integration kit>",
|
|
37
37
|
"Use mountSwitchboardWidget({ clientUrl, target }) in browser apps",
|
|
38
38
|
"Use createSwitchboardClient({ clientUrl, storage }) only for custom UI",
|
|
39
|
-
"switchboard
|
|
39
|
+
"switchboard verify setup",
|
|
40
|
+
"switchboard launch prepare --production-origin https://app.example.com --end-user-terms-url https://app.example.com/terms --end-user-privacy-url https://app.example.com/privacy --support-email support@example.com --contact-email owner@example.com --use-case \"Browser chat for signed-in customers\"",
|
|
41
|
+
"switchboard verify publish",
|
|
40
42
|
],
|
|
41
43
|
browser_smoke_test: `import { mountSwitchboardWidget } from "@switchboard.spot/sdk";
|
|
42
44
|
|
|
@@ -45,7 +47,7 @@ mountSwitchboardWidget({
|
|
|
45
47
|
target: "#switchboard",
|
|
46
48
|
});`,
|
|
47
49
|
automation_note:
|
|
48
|
-
"
|
|
50
|
+
"Client Gateway auth is browser/mobile only and requires a real browser challenge. Use account/CLI APIs only for project configuration automation.",
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production launch orchestration commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { accountRequest } from "../client.js";
|
|
6
|
+
import { saveConfig } from "../config.js";
|
|
7
|
+
import { emit, fail, globalFlags } from "../output.js";
|
|
8
|
+
import { buildProductionAccessRequestBody } from "./projects.js";
|
|
9
|
+
|
|
10
|
+
export function registerLaunchCommands(program) {
|
|
11
|
+
const launch = program.command("launch").description("Production launch workflows");
|
|
12
|
+
|
|
13
|
+
launch
|
|
14
|
+
.command("prepare")
|
|
15
|
+
.description("Prepare a project for production Client Gateway launch")
|
|
16
|
+
.option("--project-id <id>", "Existing project id")
|
|
17
|
+
.option("--project-name <name>", "Create a project when no project id is provided")
|
|
18
|
+
.option("--project-slug <slug>", "Slug for a project created by this command")
|
|
19
|
+
.requiredOption(
|
|
20
|
+
"--production-origin <origin>",
|
|
21
|
+
"Exact HTTPS production origin, for example https://app.example.com",
|
|
22
|
+
)
|
|
23
|
+
.requiredOption("--end-user-terms-url <url>")
|
|
24
|
+
.requiredOption("--end-user-privacy-url <url>")
|
|
25
|
+
.option("--support-url <url>")
|
|
26
|
+
.requiredOption("--support-email <email>")
|
|
27
|
+
.requiredOption("--contact-email <email>")
|
|
28
|
+
.requiredOption("--use-case <text>")
|
|
29
|
+
.option("--expected-monthly-volume <volume>")
|
|
30
|
+
.option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
|
|
31
|
+
.option("--notes <text>")
|
|
32
|
+
.option("--idempotency-key <key>")
|
|
33
|
+
.action(async (opts, cmd) => {
|
|
34
|
+
const flags = globalFlags(cmd);
|
|
35
|
+
validateLaunchOptions(opts, flags);
|
|
36
|
+
|
|
37
|
+
const project = await ensureProject(opts, flags);
|
|
38
|
+
const idempotencyKey = opts.idempotencyKey || `launch-prepare-project-${project.id}`;
|
|
39
|
+
|
|
40
|
+
const configured = await updateLaunchProject(project.id, opts, flags);
|
|
41
|
+
const challenge = await provisionManagedTurnstile(project.id, idempotencyKey, flags);
|
|
42
|
+
const access = await requestAccessIfNeeded(project.id, configured, opts, flags);
|
|
43
|
+
const readiness = await fetchProject(project.id, flags);
|
|
44
|
+
|
|
45
|
+
emit(
|
|
46
|
+
flags.json
|
|
47
|
+
? launchSummary(readiness, challenge, access)
|
|
48
|
+
: humanLaunchSummary(readiness, access),
|
|
49
|
+
flags,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateLaunchOptions(opts, flags) {
|
|
55
|
+
if (!productionHttpsOrigin(opts.productionOrigin)) {
|
|
56
|
+
fail("--production-origin must be an exact HTTPS production origin", 1, flags.json);
|
|
57
|
+
}
|
|
58
|
+
if ((opts.projectName && !opts.projectSlug) || (!opts.projectName && opts.projectSlug)) {
|
|
59
|
+
fail("--project-name and --project-slug must be provided together", 1, flags.json);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function ensureProject(opts, flags, { request = accountRequest, save = saveProjectConfig } = {}) {
|
|
64
|
+
if (opts.projectId) {
|
|
65
|
+
const project = await fetchProject(opts.projectId, flags, { request });
|
|
66
|
+
save(project);
|
|
67
|
+
return project;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (opts.projectName) {
|
|
71
|
+
const { data } = await request("POST", "/projects", {
|
|
72
|
+
body: { name: opts.projectName, slug: opts.projectSlug },
|
|
73
|
+
json: flags.json,
|
|
74
|
+
});
|
|
75
|
+
save(data);
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { data } = await request("GET", "/me", { json: flags.json });
|
|
80
|
+
if (!data.project?.id) {
|
|
81
|
+
fail(
|
|
82
|
+
"No default project is selected. Use --project-id or --project-name with --project-slug.",
|
|
83
|
+
1,
|
|
84
|
+
flags.json,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
save(data.project);
|
|
88
|
+
return data.project;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function updateLaunchProject(
|
|
92
|
+
projectId,
|
|
93
|
+
opts,
|
|
94
|
+
flags,
|
|
95
|
+
{ request = accountRequest, save = saveProjectConfig } = {},
|
|
96
|
+
) {
|
|
97
|
+
const body = {
|
|
98
|
+
allowed_origins: [opts.productionOrigin],
|
|
99
|
+
end_user_terms_url: opts.endUserTermsUrl,
|
|
100
|
+
end_user_privacy_url: opts.endUserPrivacyUrl,
|
|
101
|
+
support_email: opts.supportEmail,
|
|
102
|
+
virtual_microservice_enabled: true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (opts.supportUrl) body.support_url = opts.supportUrl;
|
|
106
|
+
|
|
107
|
+
const { data } = await request("PATCH", `/projects/${projectId}`, {
|
|
108
|
+
body,
|
|
109
|
+
json: flags.json,
|
|
110
|
+
});
|
|
111
|
+
save(data);
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function provisionManagedTurnstile(
|
|
116
|
+
projectId,
|
|
117
|
+
idempotencyKey,
|
|
118
|
+
flags,
|
|
119
|
+
{ request = accountRequest } = {},
|
|
120
|
+
) {
|
|
121
|
+
const { data } = await request("POST", `/projects/${projectId}/turnstile/provision`, {
|
|
122
|
+
body: { idempotency_key: `${idempotencyKey}:turnstile` },
|
|
123
|
+
json: flags.json,
|
|
124
|
+
});
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function requestAccessIfNeeded(
|
|
129
|
+
projectId,
|
|
130
|
+
project,
|
|
131
|
+
opts,
|
|
132
|
+
flags,
|
|
133
|
+
{ request = accountRequest } = {},
|
|
134
|
+
) {
|
|
135
|
+
if (project.production_access_status === "approved") {
|
|
136
|
+
return {
|
|
137
|
+
object: "production_access_request",
|
|
138
|
+
project_id: project.id,
|
|
139
|
+
project_production_access_status: "approved",
|
|
140
|
+
status: "approved",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { data } = await request("POST", `/projects/${projectId}/production_access_request`, {
|
|
145
|
+
body: buildProductionAccessRequestBody(opts),
|
|
146
|
+
json: flags.json,
|
|
147
|
+
});
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function fetchProject(projectId, flags, { request = accountRequest } = {}) {
|
|
152
|
+
const { data } = await request("GET", `/projects/${projectId}`, { json: flags.json });
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function saveProjectConfig(project) {
|
|
157
|
+
saveConfig({
|
|
158
|
+
projectId: String(project.id),
|
|
159
|
+
apiKey: null,
|
|
160
|
+
virtualMicroserviceUrl: project.virtual_microservice_url || null,
|
|
161
|
+
endUserSession: null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function launchSummary(project, challenge, access) {
|
|
166
|
+
return {
|
|
167
|
+
object: "launch_prepare_result",
|
|
168
|
+
project_id: project.id,
|
|
169
|
+
project_slug: project.slug,
|
|
170
|
+
virtual_microservice_url: project.virtual_microservice_url,
|
|
171
|
+
browser_challenge: challenge,
|
|
172
|
+
production_access: access,
|
|
173
|
+
production_safety: project.production_safety,
|
|
174
|
+
next_steps: [
|
|
175
|
+
"Run switchboard verify setup.",
|
|
176
|
+
"Use the SDK-managed browser challenge to mint browser sessions.",
|
|
177
|
+
"Run switchboard verify publish after approval and billing readiness are complete.",
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function humanLaunchSummary(project, access) {
|
|
183
|
+
const blocked = (project.production_safety?.checks || [])
|
|
184
|
+
.filter((check) => check.status !== "ready")
|
|
185
|
+
.map((check) => check.id);
|
|
186
|
+
|
|
187
|
+
return [
|
|
188
|
+
`Prepared project ${project.name} (${project.id})`,
|
|
189
|
+
`Client Gateway: ${project.virtual_microservice_url}`,
|
|
190
|
+
`Production access: ${access.project_production_access_status || access.status}`,
|
|
191
|
+
`Readiness: ${project.production_safety?.status || "unknown"}`,
|
|
192
|
+
blocked.length ? `Blocked checks: ${blocked.join(", ")}` : "Blocked checks: none",
|
|
193
|
+
"Next: run switchboard verify setup, then switchboard verify publish after approval and billing readiness.",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function productionHttpsOrigin(origin) {
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL(origin);
|
|
200
|
+
return (
|
|
201
|
+
url.protocol === "https:" &&
|
|
202
|
+
url.username === "" &&
|
|
203
|
+
url.password === "" &&
|
|
204
|
+
url.pathname === "/" &&
|
|
205
|
+
url.search === "" &&
|
|
206
|
+
url.hash === "" &&
|
|
207
|
+
!["localhost", "127.0.0.1", "::1"].includes(url.hostname) &&
|
|
208
|
+
!url.hostname.includes("*")
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/commands/projects.js
CHANGED
|
@@ -21,6 +21,7 @@ export function registerProjectsCommands(program) {
|
|
|
21
21
|
printList("Projects:", data.data || [], (p) =>
|
|
22
22
|
` ${p.id} ${p.name} (${p.slug}) — ${p.api_key_count} keys`,
|
|
23
23
|
);
|
|
24
|
+
warnForLocalhostCorsOrigins(data.data || [], flags);
|
|
24
25
|
}
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -51,6 +52,7 @@ export function registerProjectsCommands(program) {
|
|
|
51
52
|
const flags = globalFlags(cmd);
|
|
52
53
|
const { data } = await accountRequest("GET", `/projects/${id}`, { json: flags.json });
|
|
53
54
|
emit(flags.json ? data : JSON.stringify(data, null, 2), flags);
|
|
55
|
+
warnForLocalhostCorsOrigins(data, flags);
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
projects
|
|
@@ -66,7 +68,7 @@ export function registerProjectsCommands(program) {
|
|
|
66
68
|
.option("--support-email <email>")
|
|
67
69
|
.option(
|
|
68
70
|
"--allowed-origins <urls>",
|
|
69
|
-
"Comma-separated browser origins for
|
|
71
|
+
"Comma-separated browser origins for Client Gateway requests",
|
|
70
72
|
)
|
|
71
73
|
.option(
|
|
72
74
|
"--allowed-ios-bundle-ids <ids>",
|
|
@@ -86,14 +88,14 @@ export function registerProjectsCommands(program) {
|
|
|
86
88
|
json: flags.json,
|
|
87
89
|
});
|
|
88
90
|
emit(flags.json ? data : `Updated project ${data.name}`, flags);
|
|
91
|
+
warnForLocalhostCorsOrigins(data, flags);
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
projects
|
|
92
95
|
.command("turnstile <id>")
|
|
93
|
-
.description("Configure project-owned Turnstile
|
|
96
|
+
.description("Configure project-owned public Turnstile metadata")
|
|
94
97
|
.option("--site-key <key>")
|
|
95
|
-
.option("--
|
|
96
|
-
.option("--clear", "Remove project-owned Turnstile keys")
|
|
98
|
+
.option("--clear", "Remove project-owned Turnstile metadata")
|
|
97
99
|
.action(async (id, opts, cmd) => {
|
|
98
100
|
const flags = globalFlags(cmd);
|
|
99
101
|
const body = buildTurnstileBody(opts);
|
|
@@ -104,6 +106,39 @@ export function registerProjectsCommands(program) {
|
|
|
104
106
|
emit(flags.json ? data : `Updated Turnstile settings for ${data.name}`, flags);
|
|
105
107
|
});
|
|
106
108
|
|
|
109
|
+
projects
|
|
110
|
+
.command("provision-turnstile <id>")
|
|
111
|
+
.description("Provision Switchboard-managed Turnstile for a project")
|
|
112
|
+
.option("--idempotency-key <key>")
|
|
113
|
+
.action(async (id, opts, cmd) => {
|
|
114
|
+
const flags = globalFlags(cmd);
|
|
115
|
+
const body = {};
|
|
116
|
+
if (opts.idempotencyKey) body.idempotency_key = opts.idempotencyKey;
|
|
117
|
+
|
|
118
|
+
const { data } = await accountRequest("POST", `/projects/${id}/turnstile/provision`, {
|
|
119
|
+
body,
|
|
120
|
+
json: flags.json,
|
|
121
|
+
});
|
|
122
|
+
emit(flags.json ? data : `Provisioned managed Turnstile for project ${id}`, flags);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
projects
|
|
126
|
+
.command("request-production-access <id>")
|
|
127
|
+
.description("Submit a production access request")
|
|
128
|
+
.requiredOption("--contact-email <email>")
|
|
129
|
+
.requiredOption("--use-case <text>")
|
|
130
|
+
.option("--expected-monthly-volume <volume>")
|
|
131
|
+
.option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
|
|
132
|
+
.option("--notes <text>")
|
|
133
|
+
.action(async (id, opts, cmd) => {
|
|
134
|
+
const flags = globalFlags(cmd);
|
|
135
|
+
const { data } = await accountRequest("POST", `/projects/${id}/production_access_request`, {
|
|
136
|
+
body: buildProductionAccessRequestBody(opts),
|
|
137
|
+
json: flags.json,
|
|
138
|
+
});
|
|
139
|
+
emit(flags.json ? data : `Submitted production access request for project ${id}`, flags);
|
|
140
|
+
});
|
|
141
|
+
|
|
107
142
|
projects
|
|
108
143
|
.command("use <id>")
|
|
109
144
|
.description("Set default project for subsequent commands")
|
|
@@ -117,6 +152,7 @@ export function registerProjectsCommands(program) {
|
|
|
117
152
|
endUserSession: null,
|
|
118
153
|
});
|
|
119
154
|
emit(flags.json ? data : `Using project ${data.name} (${data.id})`, flags);
|
|
155
|
+
warnForLocalhostCorsOrigins(data, flags);
|
|
120
156
|
});
|
|
121
157
|
|
|
122
158
|
projects
|
|
@@ -179,6 +215,19 @@ export function buildProjectUpdateBody(opts) {
|
|
|
179
215
|
return body;
|
|
180
216
|
}
|
|
181
217
|
|
|
218
|
+
export function localhostCorsOriginWarning(project) {
|
|
219
|
+
const origins = Array.isArray(project?.allowed_origins) ? project.allowed_origins : [];
|
|
220
|
+
const localOrigins = origins.filter(isLocalhostOrigin);
|
|
221
|
+
|
|
222
|
+
if (!localOrigins.length) return null;
|
|
223
|
+
|
|
224
|
+
return [
|
|
225
|
+
`Warning: project ${project.name || project.id || "unknown"} allows localhost CORS origins`,
|
|
226
|
+
`(${localOrigins.join(", ")}).`,
|
|
227
|
+
"Use them only for development and remove them before production launch.",
|
|
228
|
+
].join(" ");
|
|
229
|
+
}
|
|
230
|
+
|
|
182
231
|
export function buildTurnstileBody(opts) {
|
|
183
232
|
if (opts.clear) {
|
|
184
233
|
return { turnstile_site_key: "", turnstile_secret_key: "" };
|
|
@@ -186,7 +235,21 @@ export function buildTurnstileBody(opts) {
|
|
|
186
235
|
|
|
187
236
|
const body = {};
|
|
188
237
|
if (opts.siteKey) body.turnstile_site_key = opts.siteKey;
|
|
189
|
-
|
|
238
|
+
return body;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function buildProductionAccessRequestBody(opts) {
|
|
242
|
+
const body = {
|
|
243
|
+
contact_email: opts.contactEmail,
|
|
244
|
+
use_case: opts.useCase,
|
|
245
|
+
needed_billing_mode: opts.neededBillingMode || "developer_paid",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (opts.expectedMonthlyVolume) {
|
|
249
|
+
body.expected_monthly_volume = opts.expectedMonthlyVolume;
|
|
250
|
+
}
|
|
251
|
+
if (opts.notes) body.notes = opts.notes;
|
|
252
|
+
|
|
190
253
|
return body;
|
|
191
254
|
}
|
|
192
255
|
|
|
@@ -195,3 +258,28 @@ function parseBoolean(value) {
|
|
|
195
258
|
if (value === false || value === "false") return false;
|
|
196
259
|
throw new Error(`Expected boolean value true or false, got ${value}`);
|
|
197
260
|
}
|
|
261
|
+
|
|
262
|
+
function warnForLocalhostCorsOrigins(projectOrProjects, flags) {
|
|
263
|
+
if (flags.json || flags.quiet) return;
|
|
264
|
+
|
|
265
|
+
const projects = Array.isArray(projectOrProjects) ? projectOrProjects : [projectOrProjects];
|
|
266
|
+
for (const project of projects) {
|
|
267
|
+
const warning = localhostCorsOriginWarning(project);
|
|
268
|
+
if (warning) console.error(warning);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isLocalhostOrigin(origin) {
|
|
273
|
+
try {
|
|
274
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
275
|
+
return (
|
|
276
|
+
hostname === "localhost" ||
|
|
277
|
+
hostname === "127.0.0.1" ||
|
|
278
|
+
hostname === "0.0.0.0" ||
|
|
279
|
+
hostname === "::1" ||
|
|
280
|
+
hostname === "[::1]"
|
|
281
|
+
);
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|