@dk/jolly 0.1.8 → 0.1.9

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.
Files changed (48) hide show
  1. package/README.md +16 -92
  2. package/package.json +26 -24
  3. package/src/index.ts +2095 -0
  4. package/src/lib/cloud-api.ts +527 -0
  5. package/src/lib/env-file.ts +68 -0
  6. package/src/lib/saleor-url.ts +37 -0
  7. package/.env.example +0 -3
  8. package/.mcp.json +0 -7
  9. package/.sisyphus/boulder.json +0 -13
  10. package/.sisyphus/notepads/saleor-agent-cli/decisions.md +0 -11
  11. package/.sisyphus/notepads/saleor-agent-cli/issues.md +0 -6
  12. package/.sisyphus/notepads/saleor-agent-cli/learnings.md +0 -6
  13. package/.sisyphus/plans/saleor-agent-cli.md +0 -600
  14. package/AGENTS.md +0 -46
  15. package/bun.lock +0 -123
  16. package/bunfig.toml +0 -8
  17. package/dist/agent.js +0 -258
  18. package/dist/bootstrap.js +0 -184
  19. package/dist/index.js +0 -722
  20. package/src/agents/index.ts +0 -1
  21. package/src/agents/setup.ts +0 -210
  22. package/src/api/auth.ts +0 -75
  23. package/src/api/client.ts +0 -152
  24. package/src/api/endpoints.ts +0 -8
  25. package/src/api/index.ts +0 -4
  26. package/src/cli/agent.ts +0 -26
  27. package/src/cli/bootstrap.ts +0 -24
  28. package/src/cli/commands/agent.ts +0 -40
  29. package/src/cli/commands/app.ts +0 -61
  30. package/src/cli/commands/config.ts +0 -38
  31. package/src/cli/commands/store.ts +0 -75
  32. package/src/cli/index.ts +0 -16
  33. package/src/commands/app.ts +0 -126
  34. package/src/commands/index.ts +0 -1
  35. package/src/commands/store.ts +0 -64
  36. package/src/test/command-handlers.test.ts +0 -232
  37. package/src/test/e2e-flows.test.ts +0 -231
  38. package/src/test/entry-points.test.ts +0 -126
  39. package/src/test/error-handling.test.ts +0 -137
  40. package/src/test/helpers.ts +0 -49
  41. package/src/test/index.ts +0 -1
  42. package/src/test/mocks.ts +0 -172
  43. package/src/test/setup.ts +0 -29
  44. package/src/tui/components.ts +0 -77
  45. package/src/tui/index.ts +0 -3
  46. package/src/tui/renderer.ts +0 -34
  47. package/src/tui/theme.ts +0 -38
  48. package/tsconfig.json +0 -20
@@ -0,0 +1,527 @@
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.
5
+ // Authenticate with `Authorization: Token <token>`.
6
+ // - Organizations: GET /platform/api/organizations/ returns a list with slug
7
+ // and environments URL.
8
+ // - Projects: POST /platform/api/organizations/{slug}/projects/ with body
9
+ // { name, plan: "dev", region }.
10
+ // - Environments: POST /platform/api/organizations/{slug}/environments/ with
11
+ // body { name, project, domain_label, database_population: "sample",
12
+ // service, region: "us-east-1" }. Returns a task_id.
13
+ // - Task status: GET /platform/api/service/task-status/{task_id} until
14
+ // status is "SUCCEEDED".
15
+ // - The environment task result contains the domain URL
16
+ // (https://{domain_label}.saleor.cloud/graphql/).
17
+ //
18
+ // Pinned by the Rule "Environment creation against in-use organizations":
19
+ // - When the Cloud API rejects environment creation because the
20
+ // organization's sandbox environment limit is reached, surface the stable
21
+ // error code ENVIRONMENT_LIMIT_REACHED.
22
+ //
23
+ // App token acquisition follows the deprecated CLI's example flow (reference
24
+ // material only, feature 012): authenticate to the instance's GraphQL API
25
+ // with the Cloud token (Bearer), select an existing local app or create one,
26
+ // and create an app token via the Saleor GraphQL API.
27
+
28
+ export const CLOUD_API_BASE = "https://cloud.saleor.io/platform/api";
29
+
30
+ const POLL_INTERVAL_MS = 5_000;
31
+ const POLL_TIMEOUT_MS = 480_000; // stay under the harness's CLI timeout
32
+
33
+ /** Error from the Cloud API with a stable, branchable code. */
34
+ export class CloudApiError extends Error {
35
+ readonly code: string;
36
+ readonly httpStatus?: number;
37
+
38
+ constructor(message: string, code: string, httpStatus?: number) {
39
+ super(message);
40
+ this.name = "CloudApiError";
41
+ this.code = code;
42
+ this.httpStatus = httpStatus;
43
+ }
44
+ }
45
+
46
+ async function cloudFetch(
47
+ url: string,
48
+ token: string,
49
+ options: RequestInit = {},
50
+ ): Promise<Response> {
51
+ return await fetch(url, {
52
+ ...options,
53
+ headers: {
54
+ Authorization: `Token ${token}`,
55
+ "Content-Type": "application/json",
56
+ ...((options.headers as Record<string, string>) ?? {}),
57
+ },
58
+ });
59
+ }
60
+
61
+ // ── Organizations ────────────────────────────────────────────────────────
62
+
63
+ export interface CloudOrganization {
64
+ slug: string;
65
+ name?: string;
66
+ [key: string]: unknown;
67
+ }
68
+
69
+ /** GET /platform/api/organizations/ */
70
+ export async function listOrganizations(
71
+ token: string,
72
+ ): Promise<CloudOrganization[]> {
73
+ const response = await cloudFetch(`${CLOUD_API_BASE}/organizations/`, token);
74
+ if (!response.ok) {
75
+ throw new CloudApiError(
76
+ `Failed to list organizations: HTTP ${response.status} ${await response.text()}`,
77
+ "CLOUD_API_ERROR",
78
+ response.status,
79
+ );
80
+ }
81
+ return (await response.json()) as CloudOrganization[];
82
+ }
83
+
84
+ // ── Projects (create-or-reuse) ───────────────────────────────────────────
85
+
86
+ export interface CloudProject {
87
+ name: string;
88
+ slug?: string;
89
+ plan?: string;
90
+ region?: string;
91
+ [key: string]: unknown;
92
+ }
93
+
94
+ /** GET /platform/api/organizations/{slug}/projects/ */
95
+ export async function listProjects(
96
+ token: string,
97
+ organizationSlug: string,
98
+ ): Promise<CloudProject[]> {
99
+ const response = await cloudFetch(
100
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/projects/`,
101
+ token,
102
+ );
103
+ if (!response.ok) {
104
+ throw new CloudApiError(
105
+ `Failed to list projects: HTTP ${response.status} ${await response.text()}`,
106
+ "CLOUD_API_ERROR",
107
+ response.status,
108
+ );
109
+ }
110
+ return (await response.json()) as CloudProject[];
111
+ }
112
+
113
+ /** POST /platform/api/organizations/{slug}/projects/ with { name, plan, region }. */
114
+ export async function createProject(
115
+ token: string,
116
+ organizationSlug: string,
117
+ body: { name: string; plan: string; region: string },
118
+ ): Promise<CloudProject> {
119
+ const response = await cloudFetch(
120
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/projects/`,
121
+ token,
122
+ { method: "POST", body: JSON.stringify(body) },
123
+ );
124
+ if (!response.ok) {
125
+ throw new CloudApiError(
126
+ `Failed to create project: HTTP ${response.status} ${await response.text()}`,
127
+ "PROJECT_CREATE_FAILED",
128
+ response.status,
129
+ );
130
+ }
131
+ return (await response.json()) as CloudProject;
132
+ }
133
+
134
+ // ── Services (concrete service identifier for environment creation) ──────
135
+
136
+ export interface CloudService {
137
+ name: string;
138
+ region?: string;
139
+ service_type?: string;
140
+ [key: string]: unknown;
141
+ }
142
+
143
+ /** GET /platform/api/organizations/{org}/projects/{project}/services/ */
144
+ export async function listProjectServices(
145
+ token: string,
146
+ organizationSlug: string,
147
+ projectSlug: string,
148
+ ): Promise<CloudService[]> {
149
+ const response = await cloudFetch(
150
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/projects/${projectSlug}/services/`,
151
+ token,
152
+ );
153
+ if (!response.ok) return [];
154
+ return (await response.json()) as CloudService[];
155
+ }
156
+
157
+ /**
158
+ * Pick the service identifier for environment creation: prefer a sandbox
159
+ * service in the default region, then any sandbox service, then the first
160
+ * listed; fall back to the spec's "saleor" default when discovery yields
161
+ * nothing.
162
+ */
163
+ export function pickService(
164
+ services: CloudService[],
165
+ region: string = "us-east-1",
166
+ ): string {
167
+ const sandbox = services.filter(
168
+ (s) => String(s.service_type ?? "").toUpperCase() === "SANDBOX",
169
+ );
170
+ const inRegion = sandbox.find((s) => s.region === region);
171
+ const chosen = inRegion ?? sandbox[0] ?? services[0];
172
+ return chosen?.name ?? "saleor";
173
+ }
174
+
175
+ // ── Environments ─────────────────────────────────────────────────────────
176
+
177
+ export interface CloudEnvironment {
178
+ key?: string;
179
+ name?: string;
180
+ domain?: string;
181
+ domain_label?: string;
182
+ task_id?: string;
183
+ [key: string]: unknown;
184
+ }
185
+
186
+ /**
187
+ * POST /platform/api/organizations/{slug}/environments/ — returns the
188
+ * environment (with task_id for async provisioning). A rejection caused by
189
+ * the organization's sandbox environment limit surfaces as a CloudApiError
190
+ * with the stable code ENVIRONMENT_LIMIT_REACHED (feature 012 Rule).
191
+ */
192
+ export async function createEnvironment(
193
+ token: string,
194
+ organizationSlug: string,
195
+ body: {
196
+ name: string;
197
+ project: string;
198
+ domain_label: string;
199
+ database_population: string;
200
+ service: string;
201
+ region: string;
202
+ },
203
+ ): Promise<CloudEnvironment> {
204
+ const response = await cloudFetch(
205
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/environments/`,
206
+ token,
207
+ { method: "POST", body: JSON.stringify(body) },
208
+ );
209
+ if (!response.ok) {
210
+ const text = await response.text();
211
+ if (
212
+ response.status >= 400 &&
213
+ response.status < 500 &&
214
+ /limit|quota|exceed/i.test(text)
215
+ ) {
216
+ throw new CloudApiError(
217
+ "The organization's sandbox environment limit is reached. " +
218
+ "Delete an unused environment or upgrade the plan, then re-run " +
219
+ "`jolly create store --create-environment`.",
220
+ "ENVIRONMENT_LIMIT_REACHED",
221
+ response.status,
222
+ );
223
+ }
224
+ throw new CloudApiError(
225
+ `Failed to create environment: HTTP ${response.status} ${text}`,
226
+ "ENVIRONMENT_CREATE_FAILED",
227
+ response.status,
228
+ );
229
+ }
230
+ return (await response.json()) as CloudEnvironment;
231
+ }
232
+
233
+ /** GET /platform/api/organizations/{slug}/environments/ */
234
+ export async function listEnvironments(
235
+ token: string,
236
+ organizationSlug: string,
237
+ ): Promise<CloudEnvironment[]> {
238
+ const response = await cloudFetch(
239
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/environments/`,
240
+ token,
241
+ );
242
+ if (!response.ok) return [];
243
+ return (await response.json()) as CloudEnvironment[];
244
+ }
245
+
246
+ /** GET /platform/api/organizations/{slug}/environments/{key}/ */
247
+ export async function getEnvironment(
248
+ token: string,
249
+ organizationSlug: string,
250
+ environmentKey: string,
251
+ ): Promise<CloudEnvironment | undefined> {
252
+ const response = await cloudFetch(
253
+ `${CLOUD_API_BASE}/organizations/${organizationSlug}/environments/${environmentKey}/`,
254
+ token,
255
+ );
256
+ if (!response.ok) return undefined;
257
+ return (await response.json()) as CloudEnvironment;
258
+ }
259
+
260
+ // ── Task polling ─────────────────────────────────────────────────────────
261
+
262
+ export interface TaskStatus {
263
+ status?: string;
264
+ [key: string]: unknown;
265
+ }
266
+
267
+ /** The poll URL for a task: GET /platform/api/service/task-status/{task_id}. */
268
+ export function taskStatusUrl(taskId: string): string {
269
+ return `${CLOUD_API_BASE}/service/task-status/${taskId}/`;
270
+ }
271
+
272
+ /**
273
+ * Poll GET /platform/api/service/task-status/{task_id} until status is
274
+ * "SUCCEEDED". Throws on FAILED or on timeout. Returns the final task body
275
+ * so the caller can extract the resulting domain from the task result.
276
+ *
277
+ * Verified against the live Cloud API: the task id is the full job name
278
+ * from the creation response, and the endpoint is anonymous — sending the
279
+ * Cloud `Authorization: Token` header makes the service try (and fail) to
280
+ * decode it as a JWT, returning 401 "Error decoding signature".
281
+ */
282
+ export async function pollTaskStatus(
283
+ taskId: string,
284
+ timeoutMs: number = POLL_TIMEOUT_MS,
285
+ ): Promise<TaskStatus> {
286
+ const deadline = Date.now() + timeoutMs;
287
+ const url = taskStatusUrl(taskId);
288
+ for (;;) {
289
+ const response = await fetch(url, {
290
+ headers: { "Content-Type": "application/json" },
291
+ });
292
+ if (!response.ok) {
293
+ throw new CloudApiError(
294
+ `Task status check failed: HTTP ${response.status} ${await response.text()}`,
295
+ "TASK_STATUS_FAILED",
296
+ response.status,
297
+ );
298
+ }
299
+ const task = (await response.json()) as TaskStatus;
300
+ const status = String(task.status ?? "").toUpperCase();
301
+ if (status === "SUCCEEDED") return task;
302
+ if (status === "FAILED" || status === "ERROR") {
303
+ throw new CloudApiError(
304
+ `Environment provisioning task ${taskId} failed: ${JSON.stringify(task)}`,
305
+ "TASK_FAILED",
306
+ );
307
+ }
308
+ if (Date.now() + POLL_INTERVAL_MS > deadline) {
309
+ throw new CloudApiError(
310
+ `Environment provisioning task ${taskId} did not reach SUCCEEDED within ${Math.round(timeoutMs / 1000)}s (last status: ${status || "unknown"})`,
311
+ "TASK_TIMEOUT",
312
+ );
313
+ }
314
+ await sleep(POLL_INTERVAL_MS);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Extract the resulting GraphQL domain URL
320
+ * (https://{domain_label}.saleor.cloud/graphql/) from the task result,
321
+ * falling back to the environment object when the task body does not carry
322
+ * it (the exact task-status shape is verified against the live API).
323
+ */
324
+ export function extractDomainUrl(
325
+ task: TaskStatus | undefined,
326
+ environment: CloudEnvironment | undefined,
327
+ domainLabel: string,
328
+ ): string {
329
+ const candidates: unknown[] = [];
330
+ if (task) {
331
+ const result = task.result as Record<string, unknown> | undefined;
332
+ candidates.push(result?.domain, task.domain, environment?.domain);
333
+ } else {
334
+ candidates.push(environment?.domain);
335
+ }
336
+ for (const candidate of candidates) {
337
+ if (typeof candidate === "string" && candidate.length > 0) {
338
+ const domain = candidate.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
339
+ return `https://${domain}/graphql/`;
340
+ }
341
+ }
342
+ return `https://${domainLabel}.saleor.cloud/graphql/`;
343
+ }
344
+
345
+ // ── Instance GraphQL: app token acquisition ──────────────────────────────
346
+
347
+ async function graphqlFetch(
348
+ graphqlUrl: string,
349
+ token: string,
350
+ query: string,
351
+ variables?: Record<string, unknown>,
352
+ ): Promise<Record<string, unknown>> {
353
+ const response = await fetch(graphqlUrl, {
354
+ method: "POST",
355
+ headers: {
356
+ Authorization: `Bearer ${token}`,
357
+ "Content-Type": "application/json",
358
+ },
359
+ body: JSON.stringify(variables ? { query, variables } : { query }),
360
+ });
361
+ if (!response.ok) {
362
+ throw new CloudApiError(
363
+ `GraphQL request to the Saleor instance failed: HTTP ${response.status}`,
364
+ "GRAPHQL_HTTP_ERROR",
365
+ response.status,
366
+ );
367
+ }
368
+ const body = (await response.json()) as Record<string, unknown>;
369
+ if (body.errors) {
370
+ throw new CloudApiError(
371
+ `GraphQL errors: ${JSON.stringify(body.errors)}`,
372
+ "GRAPHQL_ERROR",
373
+ );
374
+ }
375
+ return (body.data ?? {}) as Record<string, unknown>;
376
+ }
377
+
378
+ /** query GetApps { apps(first: 100) { edges { node { id name } } } } */
379
+ export async function queryGetApps(
380
+ graphqlUrl: string,
381
+ token: string,
382
+ ): Promise<Array<{ id: string; name: string }>> {
383
+ const data = await graphqlFetch(
384
+ graphqlUrl,
385
+ token,
386
+ `query GetApps { apps(first: 100) { edges { node { id name } } } }`,
387
+ );
388
+ const apps = data.apps as Record<string, unknown> | undefined;
389
+ const edges = (apps?.edges ?? []) as Array<Record<string, unknown>>;
390
+ return edges.map((edge) => edge.node as { id: string; name: string });
391
+ }
392
+
393
+ /** All PermissionEnum values supported by the instance. */
394
+ async function queryPermissionEnum(
395
+ graphqlUrl: string,
396
+ token: string,
397
+ ): Promise<string[]> {
398
+ const data = await graphqlFetch(
399
+ graphqlUrl,
400
+ token,
401
+ `query { __type(name: "PermissionEnum") { enumValues { name } } }`,
402
+ );
403
+ const type = data.__type as Record<string, unknown> | undefined;
404
+ const values = (type?.enumValues ?? []) as Array<Record<string, unknown>>;
405
+ return values.map((value) => String(value.name));
406
+ }
407
+
408
+ /** mutation appTokenCreate — create a token for an existing local app. */
409
+ export async function createAppToken(
410
+ graphqlUrl: string,
411
+ token: string,
412
+ appId: string,
413
+ ): Promise<{ authToken: string }> {
414
+ const data = await graphqlFetch(
415
+ graphqlUrl,
416
+ token,
417
+ `mutation AppTokenCreate($app: ID!) {
418
+ appTokenCreate(input: { app: $app }) {
419
+ authToken
420
+ errors { field message }
421
+ }
422
+ }`,
423
+ { app: appId },
424
+ );
425
+ const result = data.appTokenCreate as Record<string, unknown> | undefined;
426
+ const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
427
+ if (errors.length > 0) {
428
+ throw new CloudApiError(
429
+ `appTokenCreate failed: ${errors.map((e) => e.message).join("; ")}`,
430
+ "APP_TOKEN_CREATE_FAILED",
431
+ );
432
+ }
433
+ const authToken = result?.authToken;
434
+ if (typeof authToken !== "string" || authToken.length === 0) {
435
+ throw new CloudApiError(
436
+ "appTokenCreate did not return an authToken",
437
+ "APP_TOKEN_CREATE_FAILED",
438
+ );
439
+ }
440
+ return { authToken };
441
+ }
442
+
443
+ /** mutation appCreate — create a local app; returns its auth token directly. */
444
+ export async function createLocalApp(
445
+ graphqlUrl: string,
446
+ token: string,
447
+ name: string,
448
+ permissions: string[],
449
+ ): Promise<{ appId: string; authToken: string }> {
450
+ const data = await graphqlFetch(
451
+ graphqlUrl,
452
+ token,
453
+ `mutation AppCreate($input: AppInput!) {
454
+ appCreate(input: $input) {
455
+ authToken
456
+ app { id name }
457
+ errors { field message }
458
+ }
459
+ }`,
460
+ { input: { name, permissions } },
461
+ );
462
+ const result = data.appCreate as Record<string, unknown> | undefined;
463
+ const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
464
+ if (errors.length > 0) {
465
+ throw new CloudApiError(
466
+ `appCreate failed: ${errors.map((e) => e.message).join("; ")}`,
467
+ "APP_CREATE_FAILED",
468
+ );
469
+ }
470
+ const app = result?.app as Record<string, unknown> | undefined;
471
+ const authToken = result?.authToken;
472
+ if (typeof authToken !== "string" || authToken.length === 0) {
473
+ throw new CloudApiError(
474
+ "appCreate did not return an authToken",
475
+ "APP_CREATE_FAILED",
476
+ );
477
+ }
478
+ return { appId: String(app?.id ?? ""), authToken };
479
+ }
480
+
481
+ /**
482
+ * Acquire an app token on the instance: select an existing local app and
483
+ * create a token for it, otherwise create a local app (which yields its
484
+ * token directly) — the deprecated CLI's example flow. Retries transient
485
+ * failures: a freshly provisioned environment can take a moment to serve
486
+ * GraphQL.
487
+ */
488
+ export async function acquireAppToken(
489
+ graphqlUrl: string,
490
+ token: string,
491
+ appName: string,
492
+ ): Promise<string> {
493
+ const apps = await withRetries(() => queryGetApps(graphqlUrl, token));
494
+ if (apps.length > 0) {
495
+ const { authToken } = await createAppToken(graphqlUrl, token, apps[0].id);
496
+ return authToken;
497
+ }
498
+ const permissions = await queryPermissionEnum(graphqlUrl, token);
499
+ const { authToken } = await createLocalApp(
500
+ graphqlUrl,
501
+ token,
502
+ appName,
503
+ permissions,
504
+ );
505
+ return authToken;
506
+ }
507
+
508
+ async function withRetries<T>(
509
+ fn: () => Promise<T>,
510
+ attempts: number = 5,
511
+ delayMs: number = 5_000,
512
+ ): Promise<T> {
513
+ let lastError: unknown;
514
+ for (let attempt = 0; attempt < attempts; attempt++) {
515
+ try {
516
+ return await fn();
517
+ } catch (error) {
518
+ lastError = error;
519
+ if (attempt < attempts - 1) await sleep(delayMs);
520
+ }
521
+ }
522
+ throw lastError;
523
+ }
524
+
525
+ function sleep(ms: number): Promise<void> {
526
+ return new Promise((resolve) => setTimeout(resolve, ms));
527
+ }
@@ -0,0 +1,68 @@
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
+ }
@@ -0,0 +1,37 @@
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
+ }
package/.env.example DELETED
@@ -1,3 +0,0 @@
1
- # Saleor Cloud API Token
2
- # Get your token at: https://cloud.saleor.io/settings/api-tokens
3
- SALEOR_CLOUD_TOKEN=your-token-here
package/.mcp.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "saleor": {
4
- "url": "https://mcp.saleor.app"
5
- }
6
- }
7
- }
@@ -1,13 +0,0 @@
1
- {
2
- "active_plan": "/var/home/hands/Work/jolly/.sisyphus/plans/saleor-agent-cli.md",
3
- "started_at": "2026-04-02T12:03:12.876Z",
4
- "session_ids": [
5
- "ses_2b27f2227ffekotd1vwCAlGQKE",
6
- "ses_2b0878847ffe4HYHHUEZz4sU2v",
7
- "ses_2b079461affeWdGy097VEA4gGr",
8
- "ses_2b06fe52cffesH6qKzJwV0pSn1"
9
- ],
10
- "plan_name": "saleor-agent-cli",
11
- "agent": "atlas",
12
- "task_sessions": {}
13
- }
@@ -1,11 +0,0 @@
1
- # Decisions - Jolly CLI
2
-
3
- ## Package Structure
4
- - Single package @saleor/jolly with multiple bin entries
5
- - Bun runtime, TypeScript throughout
6
- - OpenTUI for rich terminal output
7
-
8
- ## CLI Architecture
9
- - yargs for argument parsing
10
- - Command-based structure (store, app, agent, config)
11
- - Exit codes: 0 success, 1 error
@@ -1,6 +0,0 @@
1
- # Issues - Jolly CLI
2
-
3
- ## OpenTUI Consideration
4
- - OpenTUI requires Zig to build
5
- - May need fallback to simpler TUI if Zig unavailable
6
- - Check @opentui/core availability on npm
@@ -1,6 +0,0 @@
1
- # Learnings - Jolly CLI
2
-
3
- ## Project Setup
4
- - Bun runtime for TypeScript CLI
5
- - @saleor/jolly scoped package name
6
- - Three entry points: jolly, create-saleor-jolly, init-saleor-jolly