@switchboard.spot/cli 0.2.2 → 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 +4 -6
- package/lib/commands/doctor.js +93 -76
- package/lib/commands/endUsers.js +1 -1
- package/lib/commands/init.js +4 -4
- package/lib/commands/launch.js +40 -19
- package/lib/commands/projects.js +43 -1
- package/lib/commands/setup.js +282 -7
- package/lib/commands/verify.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,9 +55,7 @@ If a first publish fails, `npm view @switchboard.spot/cli` will continue to retu
|
|
|
55
55
|
```bash
|
|
56
56
|
switchboard auth login
|
|
57
57
|
switchboard projects create --name "My App" --slug my-app
|
|
58
|
-
switchboard setup --
|
|
59
|
-
switchboard projects update <project-id> --allowed-origins http://localhost:5173 --virtual-microservice-enabled true
|
|
60
|
-
switchboard projects provision-turnstile <project-id>
|
|
58
|
+
switchboard setup project --origin http://localhost:5173 --json
|
|
61
59
|
switchboard verify setup
|
|
62
60
|
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"
|
|
63
61
|
switchboard verify publish
|
|
@@ -65,7 +63,7 @@ switchboard verify publish
|
|
|
65
63
|
|
|
66
64
|
Use `--json` for automation, CI, and coding agents.
|
|
67
65
|
|
|
68
|
-
`setup --
|
|
66
|
+
`setup project --origin <origin>` writes public Client Gateway environment values such as `VITE_SWITCHBOARD_CLIENT_URL`, configures the exact local or preview origin, enables Client Gateway, and provisions Switchboard-managed Turnstile. It reports browser chat verification as a manual SDK check.
|
|
69
67
|
|
|
70
68
|
CLI account login does not create Client Gateway end-user sessions. End-user sign-up, sign-in, and refresh require the SDK-managed browser challenge; curl, Node scripts, CI, and CLI account login cannot mint browser sessions without running that real browser/mobile flow.
|
|
71
69
|
|
|
@@ -76,11 +74,11 @@ Model discovery is global. Use `GET /v1/models` for OpenAI-compatible discovery
|
|
|
76
74
|
Switchboard-managed Turnstile is the default production path. Developers do not paste Cloudflare secrets into the CLI or repo files:
|
|
77
75
|
|
|
78
76
|
```bash
|
|
79
|
-
switchboard
|
|
77
|
+
switchboard setup project --origin <origin> --json
|
|
80
78
|
switchboard projects turnstile <project-id> --clear
|
|
81
79
|
```
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
`switchboard projects provision-turnstile` remains available as a low-level admin/debug command. If its help is missing, upgrade with `npm install -g @switchboard.spot/cli@latest` before trying dashboard automation or manual Cloudflare keys.
|
|
84
82
|
|
|
85
83
|
## Configuration
|
|
86
84
|
|
package/lib/commands/doctor.js
CHANGED
|
@@ -21,92 +21,109 @@ 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
|
+
}
|
|
26
30
|
|
|
27
|
-
|
|
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);
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
await check("health", async () => {
|
|
31
|
-
const result = await healthCheck(config);
|
|
32
|
-
return { status: result.status, data: result.data };
|
|
33
|
-
}),
|
|
34
|
-
);
|
|
40
|
+
const checks = [];
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return { status, count: Array.isArray(data?.data) ? data.data.length : 0 };
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
42
|
+
checks.push(
|
|
43
|
+
await check("health", async () => {
|
|
44
|
+
const result = await health(config);
|
|
45
|
+
return { status: result.status, data: result.data };
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
email: data?.user?.email || null,
|
|
56
|
-
tokenSource: accountConfig.accountTokenSource,
|
|
57
|
-
};
|
|
58
|
-
}),
|
|
59
|
-
);
|
|
60
|
-
} else {
|
|
61
|
-
checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
|
|
62
|
-
}
|
|
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
|
+
);
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
76
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
? "Sandbox Client Gateway config is reachable, but production launch blockers remain."
|
|
93
|
-
: null,
|
|
94
|
-
};
|
|
95
|
-
}),
|
|
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
|
+
},
|
|
96
91
|
);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
error: "No SWITCHBOARD_CLIENT_URL or VITE_SWITCHBOARD_CLIENT_URL configured",
|
|
102
|
-
});
|
|
103
|
-
}
|
|
92
|
+
const data = await readJson(res);
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
|
|
95
|
+
}
|
|
104
96
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
const productionSafety = data?.production_safety ?? null;
|
|
98
|
+
const productionBlocked = productionSafety?.status === "blocked";
|
|
99
|
+
|
|
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",
|
|
109
118
|
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
object: "doctor_report",
|
|
123
|
+
ok: checks.every((item) => item.ok),
|
|
124
|
+
baseUrl: config.baseUrl,
|
|
125
|
+
checks,
|
|
126
|
+
};
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
function ensureTrailingSlash(value) {
|
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,7 +32,7 @@ export function agentManifest(config) {
|
|
|
32
32
|
account_api: `${config.baseUrl.replace(/\/$/, "")}/v1/account`,
|
|
33
33
|
env: envBlock(clientUrl),
|
|
34
34
|
checklist: [
|
|
35
|
-
"switchboard setup --
|
|
35
|
+
"switchboard setup project --origin <origin> --json",
|
|
36
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",
|
|
@@ -47,7 +47,7 @@ mountSwitchboardWidget({
|
|
|
47
47
|
target: "#switchboard",
|
|
48
48
|
});`,
|
|
49
49
|
automation_note:
|
|
50
|
-
"
|
|
50
|
+
"Client Gateway auth is browser/mobile only and requires a real browser challenge. Use account/CLI APIs only for project configuration automation.",
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
package/lib/commands/launch.js
CHANGED
|
@@ -51,7 +51,7 @@ export function registerLaunchCommands(program) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function validateLaunchOptions(opts, flags) {
|
|
54
|
+
export function validateLaunchOptions(opts, flags) {
|
|
55
55
|
if (!productionHttpsOrigin(opts.productionOrigin)) {
|
|
56
56
|
fail("--production-origin must be an exact HTTPS production origin", 1, flags.json);
|
|
57
57
|
}
|
|
@@ -60,19 +60,23 @@ function validateLaunchOptions(opts, flags) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
async function ensureProject(opts, flags) {
|
|
64
|
-
if (opts.projectId)
|
|
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
|
+
}
|
|
65
69
|
|
|
66
70
|
if (opts.projectName) {
|
|
67
|
-
const { data } = await
|
|
71
|
+
const { data } = await request("POST", "/projects", {
|
|
68
72
|
body: { name: opts.projectName, slug: opts.projectSlug },
|
|
69
73
|
json: flags.json,
|
|
70
74
|
});
|
|
71
|
-
|
|
75
|
+
save(data);
|
|
72
76
|
return data;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
const { data } = await
|
|
79
|
+
const { data } = await request("GET", "/me", { json: flags.json });
|
|
76
80
|
if (!data.project?.id) {
|
|
77
81
|
fail(
|
|
78
82
|
"No default project is selected. Use --project-id or --project-name with --project-slug.",
|
|
@@ -80,10 +84,16 @@ async function ensureProject(opts, flags) {
|
|
|
80
84
|
flags.json,
|
|
81
85
|
);
|
|
82
86
|
}
|
|
87
|
+
save(data.project);
|
|
83
88
|
return data.project;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
async function updateLaunchProject(
|
|
91
|
+
export async function updateLaunchProject(
|
|
92
|
+
projectId,
|
|
93
|
+
opts,
|
|
94
|
+
flags,
|
|
95
|
+
{ request = accountRequest, save = saveProjectConfig } = {},
|
|
96
|
+
) {
|
|
87
97
|
const body = {
|
|
88
98
|
allowed_origins: [opts.productionOrigin],
|
|
89
99
|
end_user_terms_url: opts.endUserTermsUrl,
|
|
@@ -94,23 +104,34 @@ async function updateLaunchProject(projectId, opts, flags) {
|
|
|
94
104
|
|
|
95
105
|
if (opts.supportUrl) body.support_url = opts.supportUrl;
|
|
96
106
|
|
|
97
|
-
const { data } = await
|
|
107
|
+
const { data } = await request("PATCH", `/projects/${projectId}`, {
|
|
98
108
|
body,
|
|
99
109
|
json: flags.json,
|
|
100
110
|
});
|
|
101
|
-
|
|
111
|
+
save(data);
|
|
102
112
|
return data;
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
async function provisionManagedTurnstile(
|
|
106
|
-
|
|
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`, {
|
|
107
122
|
body: { idempotency_key: `${idempotencyKey}:turnstile` },
|
|
108
123
|
json: flags.json,
|
|
109
124
|
});
|
|
110
125
|
return data;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
async function requestAccessIfNeeded(
|
|
128
|
+
export async function requestAccessIfNeeded(
|
|
129
|
+
projectId,
|
|
130
|
+
project,
|
|
131
|
+
opts,
|
|
132
|
+
flags,
|
|
133
|
+
{ request = accountRequest } = {},
|
|
134
|
+
) {
|
|
114
135
|
if (project.production_access_status === "approved") {
|
|
115
136
|
return {
|
|
116
137
|
object: "production_access_request",
|
|
@@ -120,19 +141,19 @@ async function requestAccessIfNeeded(projectId, project, opts, flags) {
|
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
|
|
123
|
-
const { data } = await
|
|
144
|
+
const { data } = await request("POST", `/projects/${projectId}/production_access_request`, {
|
|
124
145
|
body: buildProductionAccessRequestBody(opts),
|
|
125
146
|
json: flags.json,
|
|
126
147
|
});
|
|
127
148
|
return data;
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
async function fetchProject(projectId, flags) {
|
|
131
|
-
const { data } = await
|
|
151
|
+
export async function fetchProject(projectId, flags, { request = accountRequest } = {}) {
|
|
152
|
+
const { data } = await request("GET", `/projects/${projectId}`, { json: flags.json });
|
|
132
153
|
return data;
|
|
133
154
|
}
|
|
134
155
|
|
|
135
|
-
function saveProjectConfig(project) {
|
|
156
|
+
export function saveProjectConfig(project) {
|
|
136
157
|
saveConfig({
|
|
137
158
|
projectId: String(project.id),
|
|
138
159
|
apiKey: null,
|
|
@@ -141,7 +162,7 @@ function saveProjectConfig(project) {
|
|
|
141
162
|
});
|
|
142
163
|
}
|
|
143
164
|
|
|
144
|
-
function launchSummary(project, challenge, access) {
|
|
165
|
+
export function launchSummary(project, challenge, access) {
|
|
145
166
|
return {
|
|
146
167
|
object: "launch_prepare_result",
|
|
147
168
|
project_id: project.id,
|
|
@@ -158,7 +179,7 @@ function launchSummary(project, challenge, access) {
|
|
|
158
179
|
};
|
|
159
180
|
}
|
|
160
181
|
|
|
161
|
-
function humanLaunchSummary(project, access) {
|
|
182
|
+
export function humanLaunchSummary(project, access) {
|
|
162
183
|
const blocked = (project.production_safety?.checks || [])
|
|
163
184
|
.filter((check) => check.status !== "ready")
|
|
164
185
|
.map((check) => check.id);
|
|
@@ -173,7 +194,7 @@ function humanLaunchSummary(project, access) {
|
|
|
173
194
|
].join("\n");
|
|
174
195
|
}
|
|
175
196
|
|
|
176
|
-
function productionHttpsOrigin(origin) {
|
|
197
|
+
export function productionHttpsOrigin(origin) {
|
|
177
198
|
try {
|
|
178
199
|
const url = new URL(origin);
|
|
179
200
|
return (
|
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,6 +88,7 @@ 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
|
|
@@ -149,6 +152,7 @@ export function registerProjectsCommands(program) {
|
|
|
149
152
|
endUserSession: null,
|
|
150
153
|
});
|
|
151
154
|
emit(flags.json ? data : `Using project ${data.name} (${data.id})`, flags);
|
|
155
|
+
warnForLocalhostCorsOrigins(data, flags);
|
|
152
156
|
});
|
|
153
157
|
|
|
154
158
|
projects
|
|
@@ -211,6 +215,19 @@ export function buildProjectUpdateBody(opts) {
|
|
|
211
215
|
return body;
|
|
212
216
|
}
|
|
213
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
|
+
|
|
214
231
|
export function buildTurnstileBody(opts) {
|
|
215
232
|
if (opts.clear) {
|
|
216
233
|
return { turnstile_site_key: "", turnstile_secret_key: "" };
|
|
@@ -241,3 +258,28 @@ function parseBoolean(value) {
|
|
|
241
258
|
if (value === false || value === "false") return false;
|
|
242
259
|
throw new Error(`Expected boolean value true or false, got ${value}`);
|
|
243
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
|
+
}
|
package/lib/commands/setup.js
CHANGED
|
@@ -3,22 +3,53 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { configureEnvironment } from "./env.js";
|
|
6
|
-
import {
|
|
6
|
+
import { accountRequest } from "../client.js";
|
|
7
|
+
import { emit, fail, globalFlags, redactSecrets } from "../output.js";
|
|
8
|
+
import { resolveConfig } from "../config.js";
|
|
9
|
+
import { runDoctorChecks } from "./doctor.js";
|
|
10
|
+
import {
|
|
11
|
+
ensureProject,
|
|
12
|
+
fetchProject,
|
|
13
|
+
provisionManagedTurnstile,
|
|
14
|
+
requestAccessIfNeeded,
|
|
15
|
+
saveProjectConfig,
|
|
16
|
+
validateLaunchOptions,
|
|
17
|
+
} from "./launch.js";
|
|
7
18
|
|
|
8
19
|
export function registerSetupCommand(program) {
|
|
9
|
-
program
|
|
20
|
+
const setup = program
|
|
10
21
|
.command("setup")
|
|
11
|
-
.description("Configure Switchboard
|
|
22
|
+
.description("Configure browser-ready Switchboard projects");
|
|
23
|
+
|
|
24
|
+
setup
|
|
25
|
+
.command("project")
|
|
26
|
+
.description("Create or configure a browser-ready Switchboard project")
|
|
27
|
+
.option("--project-id <id>", "Existing project id")
|
|
28
|
+
.option("--project-name <name>", "Create a project when no project id is provided")
|
|
29
|
+
.option("--project-slug <slug>", "Slug for a project created by this command")
|
|
30
|
+
.requiredOption("--origin <origin>", "Exact local, preview, or sandbox browser origin")
|
|
12
31
|
.option("--target <target>", "client, server, or both", "client")
|
|
13
32
|
.option("--file <path>", "Local env file to update", ".env.local")
|
|
14
33
|
.option("--secret-target <target>", "local or exec", "local")
|
|
15
34
|
.option("--secret-command <command>", "Command that stores secrets from stdin JSON")
|
|
16
|
-
.option("--
|
|
17
|
-
.option(
|
|
35
|
+
.option("--force", "Replace existing Switchboard-managed env values and project origins")
|
|
36
|
+
.option(
|
|
37
|
+
"--production-origin <origin>",
|
|
38
|
+
"Exact HTTPS production origin, for example https://app.example.com",
|
|
39
|
+
)
|
|
40
|
+
.option("--end-user-terms-url <url>")
|
|
41
|
+
.option("--end-user-privacy-url <url>")
|
|
42
|
+
.option("--support-url <url>")
|
|
43
|
+
.option("--support-email <email>")
|
|
44
|
+
.option("--contact-email <email>")
|
|
45
|
+
.option("--use-case <text>")
|
|
46
|
+
.option("--expected-monthly-volume <volume>")
|
|
47
|
+
.option("--needed-billing-mode <mode>", "Billing mode needed for production")
|
|
48
|
+
.option("--notes <text>")
|
|
18
49
|
.action(async (opts, cmd) => {
|
|
19
50
|
const flags = globalFlags(cmd);
|
|
20
|
-
const result = await
|
|
21
|
-
emit(flags.json ? result :
|
|
51
|
+
const result = await setupProject(opts, { json: flags.json });
|
|
52
|
+
emit(flags.json ? result : humanProjectSetupMessage(result), flags);
|
|
22
53
|
});
|
|
23
54
|
}
|
|
24
55
|
|
|
@@ -77,3 +108,247 @@ function humanSetupMessage(result) {
|
|
|
77
108
|
const skipped = result.skipped.length > 0 ? ` Skipped existing: ${result.skipped.join(", ")}.` : "";
|
|
78
109
|
return `Configured Switchboard setup (${result.target}) for project ${result.project_id}. Changed: ${changed}.${skipped}`;
|
|
79
110
|
}
|
|
111
|
+
|
|
112
|
+
export async function setupProject(
|
|
113
|
+
opts,
|
|
114
|
+
{
|
|
115
|
+
request = accountRequest,
|
|
116
|
+
configureEnv = configureEnvironment,
|
|
117
|
+
doctor = runDoctorChecks,
|
|
118
|
+
saveProject = saveProjectConfig,
|
|
119
|
+
config = resolveConfig(),
|
|
120
|
+
cwd,
|
|
121
|
+
json = false,
|
|
122
|
+
getSecret,
|
|
123
|
+
setSecret,
|
|
124
|
+
runSecretCommand,
|
|
125
|
+
} = {},
|
|
126
|
+
) {
|
|
127
|
+
validateProjectSetupOptions(opts, { json });
|
|
128
|
+
|
|
129
|
+
const flags = { json };
|
|
130
|
+
const project = await ensureProject(opts, flags, { request, save: saveProject });
|
|
131
|
+
saveProject(project);
|
|
132
|
+
|
|
133
|
+
const origins = configuredOrigins(project, opts);
|
|
134
|
+
const configured = await updateSetupProject(project.id, opts, origins, flags, {
|
|
135
|
+
request,
|
|
136
|
+
save: saveProject,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const env = await configureEnv(
|
|
140
|
+
{
|
|
141
|
+
mode: normalizeSetupTarget(opts.target, json),
|
|
142
|
+
file: opts.file,
|
|
143
|
+
target: opts.secretTarget,
|
|
144
|
+
secretCommand: opts.secretCommand,
|
|
145
|
+
projectId: String(configured.id),
|
|
146
|
+
force: opts.force,
|
|
147
|
+
},
|
|
148
|
+
{ request, cwd, json, getSecret, setSecret, runSecretCommand },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const idempotencyKey = `setup-project-${configured.id}`;
|
|
152
|
+
const challenge = await provisionManagedTurnstile(configured.id, idempotencyKey, flags, {
|
|
153
|
+
request,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
let access = null;
|
|
157
|
+
if (hasProductionOptions(opts)) {
|
|
158
|
+
access = await requestAccessIfNeeded(configured.id, configured, opts, flags, { request });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const latest = await fetchProject(configured.id, flags, { request });
|
|
162
|
+
saveProject(latest);
|
|
163
|
+
|
|
164
|
+
const doctorConfig = {
|
|
165
|
+
...config,
|
|
166
|
+
projectId: String(latest.id),
|
|
167
|
+
virtualMicroserviceUrl: latest.virtual_microservice_url || config.virtualMicroserviceUrl,
|
|
168
|
+
};
|
|
169
|
+
const readiness = await doctor({ config: doctorConfig, request });
|
|
170
|
+
|
|
171
|
+
return projectSetupSummary({
|
|
172
|
+
project: latest,
|
|
173
|
+
env,
|
|
174
|
+
challenge,
|
|
175
|
+
readiness,
|
|
176
|
+
access,
|
|
177
|
+
origin: opts.origin,
|
|
178
|
+
origins,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateProjectSetupOptions(opts, flags) {
|
|
183
|
+
if ((opts.projectName && !opts.projectSlug) || (!opts.projectName && opts.projectSlug)) {
|
|
184
|
+
fail("--project-name and --project-slug must be provided together", 1, flags.json);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!exactOrigin(opts.origin)) {
|
|
188
|
+
fail("--origin must be an exact browser origin such as http://127.0.0.1:4173", 1, flags.json);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!hasProductionOptions(opts)) return;
|
|
192
|
+
|
|
193
|
+
validateLaunchOptions(opts, flags);
|
|
194
|
+
|
|
195
|
+
for (const name of [
|
|
196
|
+
"endUserTermsUrl",
|
|
197
|
+
"endUserPrivacyUrl",
|
|
198
|
+
"supportEmail",
|
|
199
|
+
"contactEmail",
|
|
200
|
+
"useCase",
|
|
201
|
+
]) {
|
|
202
|
+
if (!opts[name]) {
|
|
203
|
+
fail(`--${dasherize(name)} is required when production launch fields are provided`, 1, flags.json);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function hasProductionOptions(opts) {
|
|
209
|
+
return [
|
|
210
|
+
"productionOrigin",
|
|
211
|
+
"endUserTermsUrl",
|
|
212
|
+
"endUserPrivacyUrl",
|
|
213
|
+
"supportUrl",
|
|
214
|
+
"supportEmail",
|
|
215
|
+
"contactEmail",
|
|
216
|
+
"useCase",
|
|
217
|
+
"expectedMonthlyVolume",
|
|
218
|
+
"neededBillingMode",
|
|
219
|
+
"notes",
|
|
220
|
+
].some((key) => opts[key] != null);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function configuredOrigins(project, opts) {
|
|
224
|
+
const requested = [opts.origin];
|
|
225
|
+
if (opts.productionOrigin) requested.push(opts.productionOrigin);
|
|
226
|
+
|
|
227
|
+
if (opts.force) return uniqueStrings(requested);
|
|
228
|
+
|
|
229
|
+
const existing = Array.isArray(project.allowed_origins) ? project.allowed_origins : [];
|
|
230
|
+
return uniqueStrings([...existing, ...requested]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function updateSetupProject(projectId, opts, origins, flags, { request, save }) {
|
|
234
|
+
const body = {
|
|
235
|
+
allowed_origins: origins,
|
|
236
|
+
virtual_microservice_enabled: true,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (hasProductionOptions(opts)) {
|
|
240
|
+
body.end_user_terms_url = opts.endUserTermsUrl;
|
|
241
|
+
body.end_user_privacy_url = opts.endUserPrivacyUrl;
|
|
242
|
+
body.support_email = opts.supportEmail;
|
|
243
|
+
if (opts.supportUrl) body.support_url = opts.supportUrl;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { data } = await request("PATCH", `/projects/${projectId}`, {
|
|
247
|
+
body,
|
|
248
|
+
json: flags.json,
|
|
249
|
+
});
|
|
250
|
+
save(data);
|
|
251
|
+
return data;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function projectSetupSummary({ project, env, challenge, readiness, access, origin, origins }) {
|
|
255
|
+
const hardFailures = readiness.checks.filter((check) => !check.ok).map((check) => check.name);
|
|
256
|
+
const configured = hardFailures.length === 0;
|
|
257
|
+
const productionRequested = Boolean(access);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
object: "setup_project_result",
|
|
261
|
+
ok: configured,
|
|
262
|
+
status: !configured
|
|
263
|
+
? "failed"
|
|
264
|
+
: productionRequested
|
|
265
|
+
? "production_requested"
|
|
266
|
+
: "ready_for_browser_manual_check",
|
|
267
|
+
configured,
|
|
268
|
+
ready_for_browser_manual_check: configured,
|
|
269
|
+
production_requested: productionRequested,
|
|
270
|
+
project_id: project.id,
|
|
271
|
+
project_slug: project.slug,
|
|
272
|
+
client_gateway_url: project.virtual_microservice_url,
|
|
273
|
+
allowed_origins: origins,
|
|
274
|
+
local_origin: origin,
|
|
275
|
+
turnstile: challenge,
|
|
276
|
+
env_file: env.env_file,
|
|
277
|
+
env,
|
|
278
|
+
doctor: readiness,
|
|
279
|
+
hard_failures: hardFailures,
|
|
280
|
+
production_access: access,
|
|
281
|
+
next_steps: [
|
|
282
|
+
"Use the SDK-managed browser challenge in your app to confirm browser chat manually.",
|
|
283
|
+
"Run switchboard verify setup after the app integration is wired.",
|
|
284
|
+
productionRequested
|
|
285
|
+
? "Run switchboard verify publish after approval and billing readiness are complete."
|
|
286
|
+
: "Use switchboard launch prepare when you are ready for production approval.",
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function humanProjectSetupMessage(result) {
|
|
292
|
+
const lines = [
|
|
293
|
+
`Configured project ${result.project_slug || result.project_id} (${result.project_id})`,
|
|
294
|
+
`Client Gateway: ${result.client_gateway_url}`,
|
|
295
|
+
`Allowed origins: ${result.allowed_origins.join(", ")}`,
|
|
296
|
+
`Turnstile: ${turnstileStatus(result.turnstile)}`,
|
|
297
|
+
`Env file: ${result.env_file}`,
|
|
298
|
+
`Readiness: ${result.configured ? "ready for browser manual check" : "failed"}`,
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
if (result.production_requested) {
|
|
302
|
+
lines.push(
|
|
303
|
+
`Production access: ${
|
|
304
|
+
result.production_access?.project_production_access_status ||
|
|
305
|
+
result.production_access?.status ||
|
|
306
|
+
"requested"
|
|
307
|
+
}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (result.hard_failures.length > 0) {
|
|
312
|
+
lines.push(`Hard failures: ${result.hard_failures.join(", ")}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
lines.push(
|
|
316
|
+
"Next: integrate the SDK browser challenge in your app, then run switchboard verify setup.",
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (result.production_requested) {
|
|
320
|
+
lines.push("Next: run switchboard verify publish after production approval and billing readiness.");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function turnstileStatus(challenge) {
|
|
327
|
+
if (!challenge) return "unknown";
|
|
328
|
+
return redactSecrets(challenge.status || challenge.provider || challenge.object || "provisioned");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function exactOrigin(origin) {
|
|
332
|
+
try {
|
|
333
|
+
const url = new URL(origin);
|
|
334
|
+
return (
|
|
335
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
336
|
+
url.username === "" &&
|
|
337
|
+
url.password === "" &&
|
|
338
|
+
url.pathname === "/" &&
|
|
339
|
+
url.search === "" &&
|
|
340
|
+
url.hash === "" &&
|
|
341
|
+
!url.hostname.includes("*")
|
|
342
|
+
);
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function uniqueStrings(values) {
|
|
349
|
+
return [...new Set(values.filter(Boolean))];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function dasherize(value) {
|
|
353
|
+
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
354
|
+
}
|
package/lib/commands/verify.js
CHANGED
|
@@ -15,7 +15,7 @@ export function registerVerifyCommands(program) {
|
|
|
15
15
|
.command("setup")
|
|
16
16
|
.description("Verify a local or preview Switchboard integration setup")
|
|
17
17
|
.option("--url <app-url>", "Application URL to test")
|
|
18
|
-
.option("--client-url <switchboard-client-url>", "Switchboard
|
|
18
|
+
.option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
|
|
19
19
|
).action(async (opts, cmd) => {
|
|
20
20
|
await runVerifyCommand("setup", opts, cmd);
|
|
21
21
|
});
|
|
@@ -25,7 +25,7 @@ export function registerVerifyCommands(program) {
|
|
|
25
25
|
.command("publish")
|
|
26
26
|
.description("Verify an HTTPS preview is safe to publish")
|
|
27
27
|
.option("--url <preview-url>", "HTTPS preview URL to test")
|
|
28
|
-
.option("--client-url <switchboard-client-url>", "Switchboard
|
|
28
|
+
.option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL")
|
|
29
29
|
.option("--production-origin <origin>", "Production origin that must be allowed"),
|
|
30
30
|
)
|
|
31
31
|
.option("--project-id <id>", "Switchboard project id for production safety checks")
|
|
@@ -38,7 +38,7 @@ export function registerVerifyCommands(program) {
|
|
|
38
38
|
.command("browser")
|
|
39
39
|
.description("Run a declarative browser verification scenario")
|
|
40
40
|
.option("--url <app-url>", "Application URL to test")
|
|
41
|
-
.option("--client-url <switchboard-client-url>", "Switchboard
|
|
41
|
+
.option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
|
|
42
42
|
).action(async (opts, cmd) => {
|
|
43
43
|
await runVerifyCommand("browser", opts, cmd);
|
|
44
44
|
});
|