@dk/jolly 0.2.0 → 0.2.1

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.
@@ -1,555 +0,0 @@
1
- // Saleor Cloud API client (feature 012 — existing Saleor store connection).
2
- //
3
- // Pinned by the feature 012 Rule "Existing-store automation principles":
4
- // - The Cloud API is at https://cloud.saleor.io/platform/api, optionally
5
- // overridden by JOLLY_SALEOR_CLOUD_API_URL (feature 018 Rule); every Cloud
6
- // API request honors the override.
7
- // Authenticate with `Authorization: Token <token>`.
8
- // - Organizations: GET /platform/api/organizations/ returns a list with slug
9
- // and environments URL.
10
- // - Projects: POST /platform/api/organizations/{slug}/projects/ with body
11
- // { name, plan: "dev", region }.
12
- // - Environments: POST /platform/api/organizations/{slug}/environments/ with
13
- // body { name, project, domain_label, database_population: "sample",
14
- // service, region: "us-east-1" }. Returns a task_id.
15
- // - Task status: GET /platform/api/service/task-status/{task_id} until
16
- // status is "SUCCEEDED".
17
- // - The environment task result contains the domain URL
18
- // (https://{domain_label}.saleor.cloud/graphql/).
19
- //
20
- // Pinned by the Rule "Environment creation against in-use organizations":
21
- // - When the Cloud API rejects environment creation because the
22
- // organization's sandbox environment limit is reached, surface the stable
23
- // error code ENVIRONMENT_LIMIT_REACHED.
24
- //
25
- // App token acquisition follows the deprecated CLI's example flow (reference
26
- // material only, feature 012): authenticate to the instance's GraphQL API
27
- // with the Cloud token (Bearer), select an existing local app or create one,
28
- // and create an app token via the Saleor GraphQL API.
29
-
30
- const DEFAULT_CLOUD_API_BASE = "https://cloud.saleor.io/platform/api";
31
-
32
- /**
33
- * The Cloud API base URL for this request: the JOLLY_SALEOR_CLOUD_API_URL
34
- * override when set (feature 018 Rule — pointing it elsewhere is the
35
- * customer's explicit choice), otherwise the first-party default.
36
- */
37
- export function cloudApiBase(): string {
38
- const override = process.env["JOLLY_SALEOR_CLOUD_API_URL"];
39
- if (override && override.trim().length > 0) {
40
- return override.trim().replace(/\/+$/, "");
41
- }
42
- return DEFAULT_CLOUD_API_BASE;
43
- }
44
-
45
- const POLL_INTERVAL_MS = 5_000;
46
- const POLL_TIMEOUT_MS = 480_000; // stay under the harness's CLI timeout
47
-
48
- /** Error from the Cloud API with a stable, branchable code. */
49
- export class CloudApiError extends Error {
50
- readonly code: string;
51
- readonly httpStatus?: number;
52
-
53
- constructor(message: string, code: string, httpStatus?: number) {
54
- super(message);
55
- this.name = "CloudApiError";
56
- this.code = code;
57
- this.httpStatus = httpStatus;
58
- }
59
- }
60
-
61
- async function cloudFetch(
62
- url: string,
63
- token: string,
64
- options: RequestInit = {},
65
- ): Promise<Response> {
66
- return await fetch(url, {
67
- ...options,
68
- headers: {
69
- Authorization: `Token ${token}`,
70
- "Content-Type": "application/json",
71
- ...((options.headers as Record<string, string>) ?? {}),
72
- },
73
- });
74
- }
75
-
76
- // ── Organizations ────────────────────────────────────────────────────────
77
-
78
- export interface CloudOrganization {
79
- slug: string;
80
- name?: string;
81
- [key: string]: unknown;
82
- }
83
-
84
- /** GET /platform/api/organizations/ */
85
- export async function listOrganizations(
86
- token: string,
87
- ): Promise<CloudOrganization[]> {
88
- const response = await cloudFetch(`${cloudApiBase()}/organizations/`, token);
89
- if (!response.ok) {
90
- throw new CloudApiError(
91
- `Failed to list organizations: HTTP ${response.status} ${await response.text()}`,
92
- "CLOUD_API_ERROR",
93
- response.status,
94
- );
95
- }
96
- return (await response.json()) as CloudOrganization[];
97
- }
98
-
99
- // ── Projects (create-or-reuse) ───────────────────────────────────────────
100
-
101
- export interface CloudProject {
102
- name: string;
103
- slug?: string;
104
- plan?: string;
105
- region?: string;
106
- [key: string]: unknown;
107
- }
108
-
109
- /** GET /platform/api/organizations/{slug}/projects/ */
110
- export async function listProjects(
111
- token: string,
112
- organizationSlug: string,
113
- ): Promise<CloudProject[]> {
114
- const response = await cloudFetch(
115
- `${cloudApiBase()}/organizations/${organizationSlug}/projects/`,
116
- token,
117
- );
118
- if (!response.ok) {
119
- throw new CloudApiError(
120
- `Failed to list projects: HTTP ${response.status} ${await response.text()}`,
121
- "CLOUD_API_ERROR",
122
- response.status,
123
- );
124
- }
125
- return (await response.json()) as CloudProject[];
126
- }
127
-
128
- /** POST /platform/api/organizations/{slug}/projects/ with { name, plan, region }. */
129
- export async function createProject(
130
- token: string,
131
- organizationSlug: string,
132
- body: { name: string; plan: string; region: string },
133
- ): Promise<CloudProject> {
134
- const response = await cloudFetch(
135
- `${cloudApiBase()}/organizations/${organizationSlug}/projects/`,
136
- token,
137
- { method: "POST", body: JSON.stringify(body) },
138
- );
139
- if (!response.ok) {
140
- throw new CloudApiError(
141
- `Failed to create project: HTTP ${response.status} ${await response.text()}`,
142
- "PROJECT_CREATE_FAILED",
143
- response.status,
144
- );
145
- }
146
- return (await response.json()) as CloudProject;
147
- }
148
-
149
- // ── Services (concrete service identifier for environment creation) ──────
150
-
151
- export interface CloudService {
152
- name: string;
153
- region?: string;
154
- service_type?: string;
155
- [key: string]: unknown;
156
- }
157
-
158
- /** GET /platform/api/organizations/{org}/projects/{project}/services/ */
159
- export async function listProjectServices(
160
- token: string,
161
- organizationSlug: string,
162
- projectSlug: string,
163
- ): Promise<CloudService[]> {
164
- const response = await cloudFetch(
165
- `${cloudApiBase()}/organizations/${organizationSlug}/projects/${projectSlug}/services/`,
166
- token,
167
- );
168
- if (!response.ok) return [];
169
- return (await response.json()) as CloudService[];
170
- }
171
-
172
- /**
173
- * Pick the service identifier for environment creation: prefer a sandbox
174
- * service in the default region, then any sandbox service, then the first
175
- * listed; fall back to the spec's "saleor" default when discovery yields
176
- * nothing.
177
- */
178
- export function pickService(
179
- services: CloudService[],
180
- region: string = "us-east-1",
181
- ): string {
182
- const sandbox = services.filter(
183
- (s) => String(s.service_type ?? "").toUpperCase() === "SANDBOX",
184
- );
185
- const inRegion = sandbox.find((s) => s.region === region);
186
- const chosen = inRegion ?? sandbox[0] ?? services[0];
187
- return chosen?.name ?? "saleor";
188
- }
189
-
190
- // ── Environments ─────────────────────────────────────────────────────────
191
-
192
- export interface CloudEnvironment {
193
- key?: string;
194
- name?: string;
195
- domain?: string;
196
- domain_label?: string;
197
- task_id?: string;
198
- [key: string]: unknown;
199
- }
200
-
201
- /**
202
- * POST /platform/api/organizations/{slug}/environments/ — returns the
203
- * environment (with task_id for async provisioning). A rejection caused by
204
- * the organization's sandbox environment limit surfaces as a CloudApiError
205
- * with the stable code ENVIRONMENT_LIMIT_REACHED (feature 012 Rule).
206
- */
207
- export async function createEnvironment(
208
- token: string,
209
- organizationSlug: string,
210
- body: {
211
- name: string;
212
- project: string;
213
- domain_label: string;
214
- database_population: string;
215
- service: string;
216
- region: string;
217
- },
218
- ): Promise<CloudEnvironment> {
219
- const response = await cloudFetch(
220
- `${cloudApiBase()}/organizations/${organizationSlug}/environments/`,
221
- token,
222
- { method: "POST", body: JSON.stringify(body) },
223
- );
224
- if (!response.ok) {
225
- const text = await response.text();
226
- if (
227
- response.status >= 400 &&
228
- response.status < 500 &&
229
- /domain/i.test(text) &&
230
- /taken|exists|already|unique|in use|duplicate/i.test(text)
231
- ) {
232
- throw new CloudApiError(
233
- `The Cloud API rejected the environment creation: the domain label ` +
234
- `"${body.domain_label}" is already taken (HTTP ${response.status}).`,
235
- "DOMAIN_LABEL_TAKEN",
236
- response.status,
237
- );
238
- }
239
- if (
240
- response.status >= 400 &&
241
- response.status < 500 &&
242
- /limit|quota|exceed/i.test(text)
243
- ) {
244
- throw new CloudApiError(
245
- "The organization's sandbox environment limit is reached. " +
246
- "Delete an unused environment or upgrade the plan, then re-run " +
247
- "`jolly create store --create-environment`.",
248
- "ENVIRONMENT_LIMIT_REACHED",
249
- response.status,
250
- );
251
- }
252
- throw new CloudApiError(
253
- `Failed to create environment: HTTP ${response.status} ${text}`,
254
- "ENVIRONMENT_CREATE_FAILED",
255
- response.status,
256
- );
257
- }
258
- return (await response.json()) as CloudEnvironment;
259
- }
260
-
261
- /** GET /platform/api/organizations/{slug}/environments/ */
262
- export async function listEnvironments(
263
- token: string,
264
- organizationSlug: string,
265
- ): Promise<CloudEnvironment[]> {
266
- const response = await cloudFetch(
267
- `${cloudApiBase()}/organizations/${organizationSlug}/environments/`,
268
- token,
269
- );
270
- if (!response.ok) return [];
271
- return (await response.json()) as CloudEnvironment[];
272
- }
273
-
274
- /** GET /platform/api/organizations/{slug}/environments/{key}/ */
275
- export async function getEnvironment(
276
- token: string,
277
- organizationSlug: string,
278
- environmentKey: string,
279
- ): Promise<CloudEnvironment | undefined> {
280
- const response = await cloudFetch(
281
- `${cloudApiBase()}/organizations/${organizationSlug}/environments/${environmentKey}/`,
282
- token,
283
- );
284
- if (!response.ok) return undefined;
285
- return (await response.json()) as CloudEnvironment;
286
- }
287
-
288
- // ── Task polling ─────────────────────────────────────────────────────────
289
-
290
- export interface TaskStatus {
291
- status?: string;
292
- [key: string]: unknown;
293
- }
294
-
295
- /** The poll URL for a task: GET /platform/api/service/task-status/{task_id}. */
296
- export function taskStatusUrl(taskId: string): string {
297
- return `${cloudApiBase()}/service/task-status/${taskId}/`;
298
- }
299
-
300
- /**
301
- * Poll GET /platform/api/service/task-status/{task_id} until status is
302
- * "SUCCEEDED". Throws on FAILED or on timeout. Returns the final task body
303
- * so the caller can extract the resulting domain from the task result.
304
- *
305
- * Verified against the live Cloud API: the task id is the full job name
306
- * from the creation response, and the endpoint is anonymous — sending the
307
- * Cloud `Authorization: Token` header makes the service try (and fail) to
308
- * decode it as a JWT, returning 401 "Error decoding signature".
309
- */
310
- export async function pollTaskStatus(
311
- taskId: string,
312
- timeoutMs: number = POLL_TIMEOUT_MS,
313
- ): Promise<TaskStatus> {
314
- const deadline = Date.now() + timeoutMs;
315
- const url = taskStatusUrl(taskId);
316
- for (;;) {
317
- const response = await fetch(url, {
318
- headers: { "Content-Type": "application/json" },
319
- });
320
- if (!response.ok) {
321
- throw new CloudApiError(
322
- `Task status check failed: HTTP ${response.status} ${await response.text()}`,
323
- "TASK_STATUS_FAILED",
324
- response.status,
325
- );
326
- }
327
- const task = (await response.json()) as TaskStatus;
328
- const status = String(task.status ?? "").toUpperCase();
329
- if (status === "SUCCEEDED") return task;
330
- if (status === "FAILED" || status === "ERROR") {
331
- throw new CloudApiError(
332
- `Environment provisioning task ${taskId} failed: ${JSON.stringify(task)}`,
333
- "TASK_FAILED",
334
- );
335
- }
336
- if (Date.now() + POLL_INTERVAL_MS > deadline) {
337
- throw new CloudApiError(
338
- `Environment provisioning task ${taskId} did not reach SUCCEEDED within ${Math.round(timeoutMs / 1000)}s (last status: ${status || "unknown"})`,
339
- "TASK_TIMEOUT",
340
- );
341
- }
342
- await sleep(POLL_INTERVAL_MS);
343
- }
344
- }
345
-
346
- /**
347
- * Extract the resulting GraphQL domain URL
348
- * (https://{domain_label}.saleor.cloud/graphql/) from the task result,
349
- * falling back to the environment object when the task body does not carry
350
- * it (the exact task-status shape is verified against the live API).
351
- */
352
- export function extractDomainUrl(
353
- task: TaskStatus | undefined,
354
- environment: CloudEnvironment | undefined,
355
- domainLabel: string,
356
- ): string {
357
- const candidates: unknown[] = [];
358
- if (task) {
359
- const result = task.result as Record<string, unknown> | undefined;
360
- candidates.push(result?.domain, task.domain, environment?.domain);
361
- } else {
362
- candidates.push(environment?.domain);
363
- }
364
- for (const candidate of candidates) {
365
- if (typeof candidate === "string" && candidate.length > 0) {
366
- const domain = candidate.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
367
- return `https://${domain}/graphql/`;
368
- }
369
- }
370
- return `https://${domainLabel}.saleor.cloud/graphql/`;
371
- }
372
-
373
- // ── Instance GraphQL: app token acquisition ──────────────────────────────
374
-
375
- async function graphqlFetch(
376
- graphqlUrl: string,
377
- token: string,
378
- query: string,
379
- variables?: Record<string, unknown>,
380
- ): Promise<Record<string, unknown>> {
381
- const response = await fetch(graphqlUrl, {
382
- method: "POST",
383
- headers: {
384
- Authorization: `Bearer ${token}`,
385
- "Content-Type": "application/json",
386
- },
387
- body: JSON.stringify(variables ? { query, variables } : { query }),
388
- });
389
- if (!response.ok) {
390
- throw new CloudApiError(
391
- `GraphQL request to the Saleor instance failed: HTTP ${response.status}`,
392
- "GRAPHQL_HTTP_ERROR",
393
- response.status,
394
- );
395
- }
396
- const body = (await response.json()) as Record<string, unknown>;
397
- if (body.errors) {
398
- throw new CloudApiError(
399
- `GraphQL errors: ${JSON.stringify(body.errors)}`,
400
- "GRAPHQL_ERROR",
401
- );
402
- }
403
- return (body.data ?? {}) as Record<string, unknown>;
404
- }
405
-
406
- /** query GetApps { apps(first: 100) { edges { node { id name } } } } */
407
- export async function queryGetApps(
408
- graphqlUrl: string,
409
- token: string,
410
- ): Promise<Array<{ id: string; name: string }>> {
411
- const data = await graphqlFetch(
412
- graphqlUrl,
413
- token,
414
- `query GetApps { apps(first: 100) { edges { node { id name } } } }`,
415
- );
416
- const apps = data.apps as Record<string, unknown> | undefined;
417
- const edges = (apps?.edges ?? []) as Array<Record<string, unknown>>;
418
- return edges.map((edge) => edge.node as { id: string; name: string });
419
- }
420
-
421
- /** All PermissionEnum values supported by the instance. */
422
- async function queryPermissionEnum(
423
- graphqlUrl: string,
424
- token: string,
425
- ): Promise<string[]> {
426
- const data = await graphqlFetch(
427
- graphqlUrl,
428
- token,
429
- `query { __type(name: "PermissionEnum") { enumValues { name } } }`,
430
- );
431
- const type = data.__type as Record<string, unknown> | undefined;
432
- const values = (type?.enumValues ?? []) as Array<Record<string, unknown>>;
433
- return values.map((value) => String(value.name));
434
- }
435
-
436
- /** mutation appTokenCreate — create a token for an existing local app. */
437
- export async function createAppToken(
438
- graphqlUrl: string,
439
- token: string,
440
- appId: string,
441
- ): Promise<{ authToken: string }> {
442
- const data = await graphqlFetch(
443
- graphqlUrl,
444
- token,
445
- `mutation AppTokenCreate($app: ID!) {
446
- appTokenCreate(input: { app: $app }) {
447
- authToken
448
- errors { field message }
449
- }
450
- }`,
451
- { app: appId },
452
- );
453
- const result = data.appTokenCreate as Record<string, unknown> | undefined;
454
- const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
455
- if (errors.length > 0) {
456
- throw new CloudApiError(
457
- `appTokenCreate failed: ${errors.map((e) => e.message).join("; ")}`,
458
- "APP_TOKEN_CREATE_FAILED",
459
- );
460
- }
461
- const authToken = result?.authToken;
462
- if (typeof authToken !== "string" || authToken.length === 0) {
463
- throw new CloudApiError(
464
- "appTokenCreate did not return an authToken",
465
- "APP_TOKEN_CREATE_FAILED",
466
- );
467
- }
468
- return { authToken };
469
- }
470
-
471
- /** mutation appCreate — create a local app; returns its auth token directly. */
472
- export async function createLocalApp(
473
- graphqlUrl: string,
474
- token: string,
475
- name: string,
476
- permissions: string[],
477
- ): Promise<{ appId: string; authToken: string }> {
478
- const data = await graphqlFetch(
479
- graphqlUrl,
480
- token,
481
- `mutation AppCreate($input: AppInput!) {
482
- appCreate(input: $input) {
483
- authToken
484
- app { id name }
485
- errors { field message }
486
- }
487
- }`,
488
- { input: { name, permissions } },
489
- );
490
- const result = data.appCreate as Record<string, unknown> | undefined;
491
- const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
492
- if (errors.length > 0) {
493
- throw new CloudApiError(
494
- `appCreate failed: ${errors.map((e) => e.message).join("; ")}`,
495
- "APP_CREATE_FAILED",
496
- );
497
- }
498
- const app = result?.app as Record<string, unknown> | undefined;
499
- const authToken = result?.authToken;
500
- if (typeof authToken !== "string" || authToken.length === 0) {
501
- throw new CloudApiError(
502
- "appCreate did not return an authToken",
503
- "APP_CREATE_FAILED",
504
- );
505
- }
506
- return { appId: String(app?.id ?? ""), authToken };
507
- }
508
-
509
- /**
510
- * Acquire an app token on the instance: select an existing local app and
511
- * create a token for it, otherwise create a local app (which yields its
512
- * token directly) — the deprecated CLI's example flow. Retries transient
513
- * failures: a freshly provisioned environment can take a moment to serve
514
- * GraphQL.
515
- */
516
- export async function acquireAppToken(
517
- graphqlUrl: string,
518
- token: string,
519
- appName: string,
520
- ): Promise<string> {
521
- const apps = await withRetries(() => queryGetApps(graphqlUrl, token));
522
- if (apps.length > 0) {
523
- const { authToken } = await createAppToken(graphqlUrl, token, apps[0].id);
524
- return authToken;
525
- }
526
- const permissions = await queryPermissionEnum(graphqlUrl, token);
527
- const { authToken } = await createLocalApp(
528
- graphqlUrl,
529
- token,
530
- appName,
531
- permissions,
532
- );
533
- return authToken;
534
- }
535
-
536
- async function withRetries<T>(
537
- fn: () => Promise<T>,
538
- attempts: number = 5,
539
- delayMs: number = 5_000,
540
- ): Promise<T> {
541
- let lastError: unknown;
542
- for (let attempt = 0; attempt < attempts; attempt++) {
543
- try {
544
- return await fn();
545
- } catch (error) {
546
- lastError = error;
547
- if (attempt < attempts - 1) await sleep(delayMs);
548
- }
549
- }
550
- throw lastError;
551
- }
552
-
553
- function sleep(ms: number): Promise<void> {
554
- return new Promise((resolve) => setTimeout(resolve, ms));
555
- }
@@ -1,68 +0,0 @@
1
- // Local .env secret handling (AGENTS.md "Secret and Environment Handling").
2
- //
3
- // Pinned harness seam (see features/step_definitions/005-stripe-checkout-setup.steps.ts):
4
- // writeEnvValues(projectDir, values) — ensures .env is ignored by Git BEFORE
5
- // writing, merges the given values into .env, and returns the full loaded
6
- // post-update value map so the current command flow can use them.
7
- // loadEnvValues(projectDir) — parses .env into a name → value record.
8
- //
9
- // The values handled here are secrets: they must never be logged or printed;
10
- // Jolly output references them by name only.
11
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
- import { join } from "node:path";
13
-
14
- const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
15
-
16
- /** Parse the project's .env into a name → value record (empty when absent). */
17
- export function loadEnvValues(projectDir: string): Record<string, string> {
18
- const path = join(projectDir, ".env");
19
- if (!existsSync(path)) return {};
20
- const values: Record<string, string> = {};
21
- for (const line of readFileSync(path, "utf8").split("\n")) {
22
- const match = ENV_LINE.exec(line);
23
- if (match) values[match[1]] = match[2];
24
- }
25
- return values;
26
- }
27
-
28
- /** Make sure .gitignore exists and lists `.env` so secrets are never committed. */
29
- function ensureEnvIgnored(projectDir: string): void {
30
- const path = join(projectDir, ".gitignore");
31
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
32
- const alreadyIgnored = existing.split("\n").some((line) => line.trim() === ".env");
33
- if (alreadyIgnored) return;
34
- const prefix = existing.length > 0 && !existing.endsWith("\n") ? `${existing}\n` : existing;
35
- writeFileSync(path, `${prefix}.env\n`);
36
- }
37
-
38
- /**
39
- * Merge values into the project's .env, ensuring .env is Git-ignored before
40
- * any secret touches disk. Existing variables are updated in place, new ones
41
- * appended, and unrelated lines (comments, other variables) are preserved.
42
- * Returns the full post-update value map for the current command flow.
43
- */
44
- export function writeEnvValues(
45
- projectDir: string,
46
- values: Record<string, string>,
47
- ): Record<string, string> {
48
- ensureEnvIgnored(projectDir);
49
- const path = join(projectDir, ".env");
50
- const lines = existsSync(path) ? readFileSync(path, "utf8").split("\n") : [];
51
- while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
52
-
53
- const pending = { ...values };
54
- const updated = lines.map((line) => {
55
- const match = ENV_LINE.exec(line);
56
- if (match && match[1] in pending) {
57
- const replacement = `${match[1]}=${pending[match[1]]}`;
58
- delete pending[match[1]];
59
- return replacement;
60
- }
61
- return line;
62
- });
63
- for (const [name, value] of Object.entries(pending)) {
64
- updated.push(`${name}=${value}`);
65
- }
66
- writeFileSync(path, `${updated.join("\n")}\n`);
67
- return loadEnvValues(projectDir);
68
- }
@@ -1,37 +0,0 @@
1
- // Normalizes a pasted Saleor URL to the canonical GraphQL endpoint.
2
- // Contract (see features/step_definitions/012-existing-saleor-store-connection.steps.ts):
3
- // normalizeSaleorUrl(input) -> { endpoint: string | null; clarification?: string }
4
- // Accepted forms: Saleor Dashboard URL, storefront API (GraphQL) URL with or
5
- // without a trailing slash, and the root Saleor Cloud URL — all normalize to
6
- // `https://<host>/graphql/`. Anything that cannot be normalized safely yields
7
- // `endpoint: null` plus a clarifying question.
8
-
9
- export interface NormalizedSaleorUrl {
10
- endpoint: string | null;
11
- clarification?: string;
12
- }
13
-
14
- const CLARIFICATION =
15
- "That doesn't look like a Saleor URL I can use. Could you paste your Saleor Dashboard URL, " +
16
- "GraphQL API URL, or root Saleor Cloud URL (for example https://your-store.eu.saleor.cloud)?";
17
-
18
- export function normalizeSaleorUrl(input: string): NormalizedSaleorUrl {
19
- let url: URL;
20
- try {
21
- url = new URL(input.trim());
22
- } catch {
23
- return { endpoint: null, clarification: CLARIFICATION };
24
- }
25
-
26
- if (url.protocol !== "https:" && url.protocol !== "http:") {
27
- return { endpoint: null, clarification: CLARIFICATION };
28
- }
29
-
30
- const path = url.pathname.replace(/\/+$/, ""); // strip trailing slashes
31
- const recognized = path === "" || path === "/graphql" || path === "/dashboard" || /^\/dashboard\//.test(url.pathname);
32
- if (!recognized) {
33
- return { endpoint: null, clarification: CLARIFICATION };
34
- }
35
-
36
- return { endpoint: `${url.protocol}//${url.host}/graphql/` };
37
- }