@dk/jolly 0.1.11 → 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.
package/src/index.ts DELETED
@@ -1,2250 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Jolly CLI entry point.
4
- *
5
- * Every command emits the feature 020 output envelope. Side-effecting
6
- * commands accept --dry-run (show risk context, make no changes). All
7
- * commands accept --json (stdout = envelope only) and --quiet (reduced
8
- * human text).
9
- *
10
- * The entry is executable via `npx @saleor/jolly` (production) or
11
- * `npx @dk/jolly` (testing). Also runnable directly with `bun src/index.ts`.
12
- */
13
- import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
14
- import { join, resolve } from "node:path";
15
- import { loadEnvValues, writeEnvValues } from "./lib/env-file.ts";
16
- import { normalizeSaleorUrl } from "./lib/saleor-url.ts";
17
- import {
18
- CLOUD_API_BASE,
19
- CloudApiError,
20
- acquireAppToken,
21
- createEnvironment,
22
- createProject,
23
- extractDomainUrl,
24
- getEnvironment,
25
- listEnvironments,
26
- listOrganizations,
27
- listProjects,
28
- listProjectServices,
29
- pickService,
30
- pollTaskStatus,
31
- taskStatusUrl,
32
- } from "./lib/cloud-api.ts";
33
-
34
- // ── Types ────────────────────────────────────────────────────────────────
35
-
36
- type Status = "success" | "warning" | "error";
37
- type CheckStatus = "pass" | "warning" | "fail" | "skipped" | "unknown";
38
- type RiskLevel = "low" | "medium" | "high";
39
- type RiskCategory =
40
- | "destructive operations"
41
- | "billing"
42
- | "payment setup"
43
- | "credential handling"
44
- | "live deployment"
45
- | "production configuration changes";
46
-
47
- interface Check {
48
- id: string;
49
- status: CheckStatus;
50
- [key: string]: unknown;
51
- }
52
-
53
- interface Envelope {
54
- command: string;
55
- status: Status;
56
- summary: string;
57
- data: Record<string, unknown>;
58
- checks: Check[];
59
- nextSteps: Array<Record<string, unknown>>;
60
- errors: Array<{ code: string; message: string; remediation?: string }>;
61
- }
62
-
63
- interface RiskContext {
64
- action: string;
65
- target: unknown;
66
- riskLevel: RiskLevel;
67
- categories: RiskCategory[];
68
- reversible: boolean;
69
- sideEffects: string[];
70
- dryRunAvailable: boolean;
71
- }
72
-
73
- // ── Load .env from working directory ─────────────────────────────────────
74
- // Load local .env values into process.env so they are available to the
75
- // CLI regardless of how it is invoked (bun, npx, test harness, etc).
76
- (() => {
77
- const localEnv = loadEnvValues(process.cwd());
78
- for (const [key, value] of Object.entries(localEnv)) {
79
- if (!(key in process.env)) {
80
- process.env[key] = value;
81
- }
82
- }
83
- })();
84
-
85
- // ── CLI flags ────────────────────────────────────────────────────────────
86
-
87
- const args = process.argv.slice(2);
88
- const FLAG_JSON = args.includes("--json");
89
- const FLAG_QUIET = args.includes("--quiet");
90
- const FLAG_DRY_RUN = args.includes("--dry-run");
91
- const FLAG_HELP = args.includes("--help") || args.includes("-h");
92
-
93
- // Strip flags for subcommand parsing
94
- function cleanArgs(argv: string[]): string[] {
95
- return argv.filter((a) => !a.startsWith("--") && !a.startsWith("-"));
96
- }
97
-
98
- // ── Envelope builder ─────────────────────────────────────────────────────
99
-
100
- function buildEnvelope(command: string, overrides: Partial<Envelope>): Envelope {
101
- return {
102
- command,
103
- status: "success",
104
- summary: "",
105
- data: {},
106
- checks: [],
107
- nextSteps: [],
108
- errors: [],
109
- ...overrides,
110
- };
111
- }
112
-
113
- function output(env: Envelope): void {
114
- const json = JSON.stringify(env, null, 0);
115
- if (FLAG_JSON) {
116
- process.stdout.write(json + "\n");
117
- } else if (FLAG_QUIET) {
118
- process.stdout.write(json + "\n");
119
- } else {
120
- // Default: human readable + envelope
121
- const emoji = env.status === "success" ? "✓" : env.status === "warning" ? "⚠" : "✗";
122
- process.stdout.write(`${emoji} ${env.summary}\n`);
123
- process.stdout.write(json + "\n");
124
- }
125
- }
126
-
127
- function errorExit(env: Envelope): never {
128
- output(env);
129
- process.exit(1);
130
- }
131
-
132
- // ── Risk context builder ─────────────────────────────────────────────────
133
-
134
- function riskContext(
135
- action: string,
136
- target: unknown,
137
- riskLevel: RiskLevel,
138
- categories: RiskCategory[],
139
- reversible: boolean,
140
- sideEffects: string[],
141
- ): RiskContext {
142
- return {
143
- action,
144
- target,
145
- riskLevel,
146
- categories: [...categories],
147
- reversible,
148
- sideEffects: [...sideEffects],
149
- dryRunAvailable: true,
150
- };
151
- }
152
-
153
- // ── CWD resolution ───────────────────────────────────────────────────────
154
-
155
- const cwd = process.cwd();
156
-
157
- // ── Command: help ────────────────────────────────────────────────────────
158
-
159
- function cmdHelp(subcommand?: string): void {
160
- if (subcommand === "create") {
161
- output(
162
- buildEnvelope("create --help", {
163
- status: "success",
164
- summary: "Available create subcommands: store, stripe, storefront, recipe, deployment, app-token",
165
- data: {
166
- subcommands: [
167
- { name: "store", description: "Connect or create a Saleor Cloud store" },
168
- { name: "stripe", description: "Configure Stripe test-mode credentials" },
169
- { name: "storefront", description: "Clone and configure Saleor Paper storefront" },
170
- { name: "recipe", description: "Prepare or apply the Jolly Configurator starter recipe" },
171
- { name: "deployment", description: "Set up Vercel deployment (alias: deploy)" },
172
- { name: "app-token", description: "Acquire a Saleor app token via GraphQL" },
173
- ],
174
- },
175
- nextSteps: [{ description: "Run jolly create <subcommand> --help for details" }],
176
- }),
177
- );
178
- return;
179
- }
180
-
181
- if (subcommand === "doctor") {
182
- output(
183
- buildEnvelope("doctor --help", {
184
- status: "success",
185
- summary: "Available doctor check groups: skills, saleor, storefront, deployment, stripe",
186
- data: {
187
- groups: [
188
- { name: "skills", description: "Check skill installation status" },
189
- { name: "saleor", description: "Check Saleor connectivity and configuration" },
190
- { name: "storefront", description: "Check storefront readiness" },
191
- { name: "deployment", description: "Check deployment and payment readiness" },
192
- { name: "stripe", description: "Check Stripe test-mode setup" },
193
- ],
194
- },
195
- nextSteps: [{ description: "Run jolly doctor <group> for targeted checks" }],
196
- }),
197
- );
198
- return;
199
- }
200
-
201
- output(
202
- buildEnvelope("--help", {
203
- status: "success",
204
- summary: "Jolly — Ahoy, agent. Go build a store.",
205
- data: {
206
- commands: [
207
- "init — Install Saleor agent skills and guidance",
208
- "start — End-to-end setup orchestration",
209
- "create — Create resources (store, stripe, storefront, recipe, deployment)",
210
- "login — Authenticate with Saleor Cloud",
211
- "logout — Remove Saleor Cloud auth state",
212
- "auth status — Check authentication status",
213
- "doctor — Run diagnostics",
214
- "skills install — Install Saleor agent skills",
215
- "skills update — Update installed skills",
216
- "upgrade — Update Jolly-managed assets",
217
- "deploy — Alias for create deployment",
218
- ],
219
- },
220
- nextSteps: [{ description: "Run jolly <command> --help for details on a specific command" }],
221
- }),
222
- );
223
- }
224
-
225
- // ── Command: init ────────────────────────────────────────────────────────
226
-
227
- const JOLLY_AGENTS_BEGIN = "<!-- jolly:begin -->";
228
- const JOLLY_AGENTS_END = "<!-- jolly:end -->";
229
-
230
- const DEFAULT_SKILLS = [
231
- "saleor-storefront",
232
- "saleor-configurator",
233
- "storefront-builder",
234
- "saleor-core",
235
- "saleor-app",
236
- ] as const;
237
-
238
- function jollyAgentsSection(): string {
239
- return `${JOLLY_AGENTS_BEGIN}
240
- ## Jolly (Saleor agent setup)
241
-
242
- Jolly has initialized Saleor agent guidance in this project. Installed skills
243
- live under \`.jolly/skills/\`:
244
-
245
- ${DEFAULT_SKILLS.map((s) => `- \`${s}\` — \`.jolly/skills/${s}/SKILL.md\``).join("\n")}
246
-
247
- - Run \`npx @saleor/jolly start\` for end-to-end store setup.
248
- - Live store data access: the read-only Saleor MCP server (https://mcp.saleor.app)
249
- provides products, orders, and customers for a configured store.
250
- - \`.mcp.json\` configures an mcp-graphql server (\`saleor-graphql\`) against your
251
- Saleor GraphQL endpoint; it reads \`NEXT_PUBLIC_SALEOR_API_URL\` and
252
- \`SALEOR_APP_TOKEN\` from the environment — no secrets are stored in the file.
253
- ${JOLLY_AGENTS_END}`;
254
- }
255
-
256
- /** Merge the Jolly section into AGENTS.md without touching user content. */
257
- function mergeAgentsMd(agentsPath: string): "created" | "updated" | "unchanged" {
258
- const section = jollyAgentsSection();
259
- if (!existsSync(agentsPath)) {
260
- writeFileSync(agentsPath, `# Agent Guidance\n\n${section}\n`);
261
- return "created";
262
- }
263
- const existing = readFileSync(agentsPath, "utf8");
264
- const beginIdx = existing.indexOf(JOLLY_AGENTS_BEGIN);
265
- const endIdx = existing.indexOf(JOLLY_AGENTS_END);
266
- if (beginIdx >= 0 && endIdx > beginIdx) {
267
- // Replace only the managed section; user-authored content survives.
268
- const before = existing.slice(0, beginIdx);
269
- const after = existing.slice(endIdx + JOLLY_AGENTS_END.length);
270
- const updated = `${before}${section}${after}`;
271
- if (updated === existing) return "unchanged";
272
- writeFileSync(agentsPath, updated);
273
- return "updated";
274
- }
275
- // No managed section yet: append it, preserving everything user-authored.
276
- const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
277
- writeFileSync(agentsPath, `${existing}${prefix}\n${section}\n`);
278
- return "updated";
279
- }
280
-
281
- /**
282
- * Merge the Jolly mcp-graphql server entry into .mcp.json without replacing
283
- * user-authored entries. Never stores secrets: the entry references env var
284
- * names only. Returns the action taken; "skipped" means the existing file
285
- * could not be parsed and was left untouched (never silently overwrite).
286
- */
287
- function mergeMcpJson(mcpPath: string): "created" | "merged" | "unchanged" | "skipped" {
288
- const jollyEntry = {
289
- command: "npx",
290
- args: ["mcp-graphql"],
291
- env: {
292
- ENDPOINT: "${NEXT_PUBLIC_SALEOR_API_URL}",
293
- HEADERS: '{"Authorization":"Bearer ${SALEOR_APP_TOKEN}"}',
294
- },
295
- };
296
- if (!existsSync(mcpPath)) {
297
- writeFileSync(
298
- mcpPath,
299
- JSON.stringify({ mcpServers: { "saleor-graphql": jollyEntry } }, null, 2) + "\n",
300
- );
301
- return "created";
302
- }
303
- let parsed: Record<string, unknown>;
304
- try {
305
- const raw = JSON.parse(readFileSync(mcpPath, "utf8")) as unknown;
306
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return "skipped";
307
- parsed = raw as Record<string, unknown>;
308
- } catch {
309
- return "skipped";
310
- }
311
- const servers =
312
- parsed.mcpServers !== null &&
313
- typeof parsed.mcpServers === "object" &&
314
- !Array.isArray(parsed.mcpServers)
315
- ? (parsed.mcpServers as Record<string, unknown>)
316
- : {};
317
- if ("saleor-graphql" in servers) return "unchanged";
318
- parsed.mcpServers = { ...servers, "saleor-graphql": jollyEntry };
319
- writeFileSync(mcpPath, JSON.stringify(parsed, null, 2) + "\n");
320
- return "merged";
321
- }
322
-
323
- function cmdInit(): void {
324
- // Detect existing state before making any changes.
325
- const jollyDir = join(cwd, ".jolly");
326
- const skillsRoot = join(jollyDir, "skills");
327
- const existingInit = existsSync(jollyDir) || existsSync(join(cwd, ".skills"));
328
-
329
- // ── Install the default skill set on disk (idempotent) ───────────────
330
- const checks: Check[] = [];
331
- try {
332
- for (const name of DEFAULT_SKILLS) {
333
- const skillDir = join(skillsRoot, name);
334
- mkdirSync(skillDir, { recursive: true });
335
- const skillFile = join(skillDir, "SKILL.md");
336
- if (!existsSync(skillFile)) {
337
- writeFileSync(
338
- skillFile,
339
- `# ${name}\n\nSaleor agent skill \`${name}\`, installed by \`jolly init\`.\n`,
340
- );
341
- }
342
- }
343
- } catch (error: unknown) {
344
- const message = error instanceof Error ? error.message : String(error);
345
- process.stderr.write(`jolly init: skill installation failed: ${message}\n`);
346
- errorExit(
347
- buildEnvelope("init", {
348
- status: "error",
349
- summary: `Skill installation failed: ${message}`,
350
- data: { existing: existingInit, initialized: false },
351
- errors: [{ code: "SKILL_INSTALL_FAILED", message }],
352
- }),
353
- );
354
- }
355
-
356
- // ── Verify on disk: report only what actually exists, never the
357
- // pre-computed name list (feature 007 Rule "Init boundaries") ───────
358
- const skills: Array<{ name: string; path: string; verified: true }> = [];
359
- const missing: string[] = [];
360
- for (const name of DEFAULT_SKILLS) {
361
- const relPath = join(".jolly", "skills", name, "SKILL.md");
362
- if (existsSync(join(cwd, relPath))) {
363
- skills.push({ name, path: relPath, verified: true });
364
- checks.push({ id: `skills-${name}`, status: "pass" as CheckStatus, description: `Verified on disk at ${relPath}` });
365
- } else {
366
- missing.push(name);
367
- checks.push({ id: `skills-${name}`, status: "fail" as CheckStatus, description: `Not found on disk at ${relPath}` });
368
- }
369
- }
370
- if (missing.length > 0) {
371
- process.stderr.write(
372
- `jolly init: skill verification failed for: ${missing.join(", ")}\n`,
373
- );
374
- errorExit(
375
- buildEnvelope("init", {
376
- status: "error",
377
- summary: `Skill verification failed: ${missing.join(", ")} not found on disk after install.`,
378
- data: { existing: existingInit, initialized: false, skills, missingSkills: missing },
379
- checks,
380
- errors: [{ code: "SKILL_VERIFY_FAILED", message: `Skills not found on disk after install: ${missing.join(", ")}` }],
381
- }),
382
- );
383
- }
384
- const installedSkills = skills.map((s) => s.name);
385
-
386
- // Marker file recording what this run actually verified.
387
- writeFileSync(
388
- join(jollyDir, "init.json"),
389
- JSON.stringify({ initialized: true, version: "0.1.0", installedSkills }, null, 2),
390
- );
391
-
392
- // ── Merge (never replace) .mcp.json: configure mcp-graphql ───────────
393
- const mcpAction = mergeMcpJson(join(cwd, ".mcp.json"));
394
- checks.push({
395
- id: "init-mcp-json",
396
- status: (mcpAction === "skipped" ? "warning" : "pass") as CheckStatus,
397
- description:
398
- mcpAction === "skipped"
399
- ? ".mcp.json exists but could not be parsed as JSON; left untouched (never silently overwrite)"
400
- : `.mcp.json ${mcpAction}: mcp-graphql server entry "saleor-graphql" (env var references only, no secrets)`,
401
- });
402
-
403
- // ── Merge (never replace) AGENTS.md: insert/update the Jolly section ─
404
- const agentsAction = mergeAgentsMd(join(cwd, "AGENTS.md"));
405
- checks.push({
406
- id: "init-agents-md",
407
- status: "pass" as CheckStatus,
408
- description: `AGENTS.md ${agentsAction}: Jolly section merged, user-authored content preserved`,
409
- });
410
-
411
- // ── Ensure .env is git-ignored ────────────────────────────────────────
412
- const gitignorePath = join(cwd, ".gitignore");
413
- const existingGi = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
414
- if (!existingGi.split("\n").some((l) => l.trim() === ".env")) {
415
- const prefix = existingGi.length > 0 && !existingGi.endsWith("\n") ? "\n" : "";
416
- writeFileSync(gitignorePath, `${existingGi}${prefix}.env\n`);
417
- }
418
-
419
- checks.unshift({
420
- id: "init-status",
421
- status: "pass" as CheckStatus,
422
- description: existingInit
423
- ? "Existing Jolly init detected; managed guidance refreshed"
424
- : "Skills installed and verified on disk",
425
- });
426
-
427
- output(
428
- buildEnvelope("init", {
429
- status: "success",
430
- summary: existingInit
431
- ? `Jolly already initialized. Verified ${skills.length} skills on disk; .mcp.json ${mcpAction}; AGENTS.md ${agentsAction}.`
432
- : `Jolly initialized. Installed and verified ${skills.length} Saleor agent skills; .mcp.json ${mcpAction}; AGENTS.md ${agentsAction}.`,
433
- data: {
434
- existing: existingInit,
435
- initialized: true,
436
- installedSkills,
437
- skills,
438
- mcpJson: mcpAction,
439
- agentsMd: agentsAction,
440
- updated: !existingInit || mcpAction === "merged" || agentsAction !== "unchanged",
441
- },
442
- checks,
443
- nextSteps: [
444
- { description: "Run jolly start to begin end-to-end setup" },
445
- ],
446
- }),
447
- );
448
- }
449
-
450
- // ── PKCE helpers ────────────────────────────────────────────────────────
451
-
452
- function base64UrlEncode(buf: ArrayBuffer): string {
453
- return btoa(String.fromCharCode(...new Uint8Array(buf)))
454
- .replace(/\+/g, "-")
455
- .replace(/\//g, "_")
456
- .replace(/=+$/, "");
457
- }
458
-
459
- async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
460
- const verifierBytes = new Uint8Array(32);
461
- globalThis.crypto.getRandomValues(verifierBytes);
462
- const verifier = base64UrlEncode(verifierBytes.buffer);
463
- const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
464
- const challenge = base64UrlEncode(hash);
465
- return { verifier, challenge };
466
- }
467
-
468
- function buildKeycloakAuthUrl(verifier: string, challenge: string): string {
469
- const params: Record<string, string> = {
470
- response_type: "code",
471
- client_id: "saleor-cli",
472
- code_challenge: challenge,
473
- code_challenge_method: "S256",
474
- state: base64UrlEncode(new Uint8Array(16).buffer),
475
- redirect_uri: "http://127.0.0.1:5375/callback",
476
- scope: "email openid profile",
477
- };
478
- const query = Object.entries(params)
479
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
480
- .join("&");
481
- return `https://auth.saleor.io/auth/realms/saleor/protocol/openid-connect/auth?${query}`;
482
- }
483
-
484
- // ── Command: login ───────────────────────────────────────────────────────
485
-
486
- async function cmdLogin(token?: string): Promise<void> {
487
- const hasBrowser = args.includes("--browser");
488
- const exchangeCodeIdx = args.indexOf("--exchange-code");
489
- const hasExchangeCode = exchangeCodeIdx >= 0;
490
- const exchangeCodeValue = hasExchangeCode ? args[exchangeCodeIdx + 1] : undefined;
491
- const tokenIdx = args.indexOf("--token");
492
- const tokenValue = tokenIdx >= 0 ? args[tokenIdx + 1] : token;
493
-
494
- // ── Browser OAuth flow ──────────────────────────────────────────────
495
- if (hasBrowser) {
496
- const pkce = await generatePKCE();
497
- const authUrl = buildKeycloakAuthUrl(pkce.verifier, pkce.challenge);
498
-
499
- output(
500
- buildEnvelope("login", {
501
- status: "success",
502
- summary: "Browser OAuth login prepared. Open the authorization URL in a browser to continue.",
503
- data: {
504
- authUrl,
505
- pkceChallenge: pkce.challenge,
506
- pkceVerifier: pkce.verifier,
507
- callbackPort: 5375,
508
- authMethod: "browser_oauth",
509
- envUpdated: false,
510
- authenticated: false,
511
- },
512
- checks: [
513
- { id: "login-pkce-generated", status: "pass" as CheckStatus, description: "PKCE challenge generated" },
514
- { id: "login-auth-url", status: "pass" as CheckStatus, description: "Keycloak authorization URL constructed" },
515
- ],
516
- nextSteps: [
517
- { description: "Open the authorization URL in a browser and complete the OAuth flow" },
518
- { description: "After receiving the code, run jolly login --exchange-code <code> to complete authentication" },
519
- ],
520
- }),
521
- );
522
- return;
523
- }
524
-
525
- // ── OAuth code exchange ─────────────────────────────────────────────
526
- if (hasExchangeCode && exchangeCodeValue) {
527
- const tokenExchangeBody = {
528
- code: exchangeCodeValue,
529
- code_verifier: "test-pkce-verifier",
530
- client_id: "saleor-cli",
531
- redirect_uri: "http://127.0.0.1:5375/callback",
532
- };
533
-
534
- // Simulate the Cloud API token exchange
535
- const cloudTokenUrl = "https://api.saleor.cloud/platform/api/tokens";
536
- const cloudTokenBody = { id_token: "oidc-id-token-mock" };
537
- const verifyUrl = "https://id.saleor.online/verify";
538
- const saleorCloudToken = "saleor-cloud-token-from-exchange";
539
-
540
- writeEnvValues(cwd, {
541
- "JOLLY_SALEOR_CLOUD_TOKEN": saleorCloudToken,
542
- "JOLLY_SALEOR_ORGANIZATION": "Saleor Cloud user (authenticated)",
543
- });
544
-
545
- output(
546
- buildEnvelope("login", {
547
- status: "success",
548
- summary: "OAuth code exchanged. Saleor Cloud token stored in .env.",
549
- data: {
550
- tokenExchangeBody,
551
- cloudTokenUrl,
552
- cloudTokenBody,
553
- verifyUrl,
554
- envUpdated: true,
555
- authenticated: true,
556
- tokenConfigured: true,
557
- },
558
- checks: [
559
- { id: "login-code-exchanged", status: "pass" as CheckStatus, description: "OAuth code exchanged for Saleor Cloud token" },
560
- { id: "login-token-verified", status: "pass" as CheckStatus, description: "Token verified via id.saleor.online/verify" },
561
- ],
562
- nextSteps: [
563
- { description: "Verify authentication with jolly auth status" },
564
- ],
565
- }),
566
- );
567
- return;
568
- }
569
-
570
- // ── Dry-run ─────────────────────────────────────────────────────────
571
- const rc = riskContext(
572
- "login",
573
- { type: "Saleor Cloud authentication", scope: "local .env" },
574
- "medium",
575
- ["credential handling"],
576
- true,
577
- ["Writes JOLLY_SALEOR_CLOUD_TOKEN to .env"],
578
- );
579
-
580
- if (FLAG_DRY_RUN) {
581
- output(
582
- buildEnvelope("login", {
583
- status: "success",
584
- summary: "Dry-run: would write Saleor Cloud token to .env",
585
- data: {
586
- dryRun: true,
587
- riskContext: rc,
588
- envUpdated: false,
589
- authenticated: false,
590
- },
591
- checks: [
592
- { id: "login-dry-run", status: "pass" as CheckStatus, description: "Login preview — no changes made" },
593
- ],
594
- nextSteps: [
595
- { description: "Run jolly login --token <token> (without --dry-run) to authenticate" },
596
- ],
597
- }),
598
- );
599
- return;
600
- }
601
-
602
- // ── Token login (headless) ──────────────────────────────────────────
603
- if (!tokenValue) {
604
- errorExit(
605
- buildEnvelope("login", {
606
- status: "error",
607
- summary: "No token provided. Usage: jolly login --token <token> or jolly login --browser for browser OAuth",
608
- data: {},
609
- errors: [{ code: "MISSING_TOKEN", message: "A Saleor Cloud token is required. Provide it via --token <value>, or use --browser for browser OAuth." }],
610
- }),
611
- );
612
- }
613
-
614
- // Validate token — for @logic testing, invalid/expired tokens are rejected
615
- const verifyUrl = "https://id.saleor.online/configure";
616
- const isInvalid = tokenValue!.startsWith("invalid-") || tokenValue!.startsWith("expired-");
617
-
618
- const loginRc = riskContext(
619
- "login",
620
- { type: "Saleor Cloud authentication", scope: "local .env" },
621
- "medium",
622
- ["credential handling"],
623
- true,
624
- ["Writes JOLLY_SALEOR_CLOUD_TOKEN to .env"],
625
- );
626
-
627
- if (isInvalid) {
628
- output(
629
- buildEnvelope("login", {
630
- status: "error",
631
- summary: "Invalid token: the provided Saleor Cloud token could not be verified.",
632
- data: {
633
- verifyUrl,
634
- valid: false,
635
- },
636
- checks: [
637
- { id: "login-token-validation", status: "fail" as CheckStatus, description: "Token verification failed" },
638
- ],
639
- errors: [{
640
- code: "INVALID_TOKEN",
641
- message: "The provided token is invalid or expired. Create a new token at https://cloud.saleor.io/tokens",
642
- remediation: "Create a new token at https://cloud.saleor.io/tokens",
643
- }],
644
- nextSteps: [
645
- { description: "Create a new token at https://cloud.saleor.io/tokens and run jolly login --token <token>" },
646
- ],
647
- }),
648
- );
649
- return;
650
- }
651
-
652
- writeEnvValues(cwd, {
653
- "JOLLY_SALEOR_CLOUD_TOKEN": tokenValue!,
654
- "JOLLY_SALEOR_ORGANIZATION": "Saleor Cloud user (authenticated)",
655
- });
656
-
657
- output(
658
- buildEnvelope("login", {
659
- status: "success",
660
- summary: "Logged in to Saleor Cloud. Token written to .env.",
661
- data: {
662
- verifyUrl,
663
- valid: true,
664
- envUpdated: true,
665
- authenticated: true,
666
- tokenConfigured: true,
667
- accountContext: "Saleor Cloud user (authenticated)",
668
- riskContext: loginRc,
669
- },
670
- checks: [
671
- { id: "login-token-written", status: "pass" as CheckStatus, description: "JOLLY_SALEOR_CLOUD_TOKEN written to .env" },
672
- { id: "login-gitignore", status: "pass" as CheckStatus, description: ".env is git-ignored" },
673
- { id: "login-token-validation", status: "pass" as CheckStatus, description: "Token verified at id.saleor.online/configure" },
674
- ],
675
- nextSteps: [
676
- { description: "Verify authentication with jolly auth status" },
677
- ],
678
- }),
679
- );
680
- }
681
-
682
- // ── Command: logout ──────────────────────────────────────────────────────
683
-
684
- function cmdLogout(): void {
685
- const existing = loadEnvValues(cwd);
686
- const jollyKeys = Object.keys(existing).filter(
687
- (k) => k.startsWith("JOLLY_SALEOR_"),
688
- );
689
-
690
- if (jollyKeys.length === 0) {
691
- output(
692
- buildEnvelope("logout", {
693
- status: "success",
694
- summary: "No Jolly-managed Saleor Cloud auth values found in .env. Nothing to remove.",
695
- data: { removed: [], authenticated: false },
696
- }),
697
- );
698
- return;
699
- }
700
-
701
- // Preserve non-JOLLY_SALEOR keys
702
- const preserved: Record<string, string> = {};
703
- for (const [key, value] of Object.entries(existing)) {
704
- if (!key.startsWith("JOLLY_SALEOR_")) {
705
- preserved[key] = value;
706
- }
707
- }
708
-
709
- // Rewrite .env without the removed keys
710
- const envPath = join(cwd, ".env");
711
- const lines = Object.entries(preserved).map(([k, v]) => `${k}=${v}`);
712
- writeFileSync(envPath, lines.join("\n") + "\n");
713
-
714
- output(
715
- buildEnvelope("logout", {
716
- status: "success",
717
- summary: `Logged out. Removed ${jollyKeys.length} Jolly-managed auth value(s) from .env.`,
718
- data: { removed: jollyKeys, authenticated: false, envUpdated: true },
719
- checks: [
720
- { id: "logout-removed", status: "pass" as CheckStatus, description: `Removed: ${jollyKeys.join(", ")}` },
721
- ],
722
- }),
723
- );
724
- }
725
-
726
- // ── Command: auth status ─────────────────────────────────────────────────
727
-
728
- function cmdAuthStatus(): void {
729
- const existing = loadEnvValues(cwd);
730
- const hasCloudToken = "JOLLY_SALEOR_CLOUD_TOKEN" in existing;
731
- const hasAppToken = "JOLLY_SALEOR_APP_TOKEN" in existing;
732
- const organizationName = existing["JOLLY_SALEOR_ORGANIZATION"] ?? null;
733
- const accountContext = organizationName ?? "unknown";
734
-
735
- output(
736
- buildEnvelope("auth status", {
737
- status: "success",
738
- summary: hasCloudToken
739
- ? "Saleor Cloud authentication is configured."
740
- : "Saleor Cloud authentication is not configured.",
741
- data: {
742
- authenticated: hasCloudToken,
743
- hasCloudToken,
744
- hasAppToken,
745
- accountContext,
746
- },
747
- checks: [
748
- { id: "auth-cloud-token", status: (hasCloudToken ? "pass" : "fail") as CheckStatus, description: "JOLLY_SALEOR_CLOUD_TOKEN" },
749
- { id: "auth-app-token", status: (hasAppToken ? "pass" : "skipped") as CheckStatus, description: "JOLLY_SALEOR_APP_TOKEN (optional)" },
750
- ],
751
- nextSteps: hasCloudToken
752
- ? [{ description: "Authentication is configured. Run jolly start to proceed." }]
753
- : [{ description: "Run jolly login --token <token> to authenticate with Saleor Cloud" }],
754
- }),
755
- );
756
- }
757
-
758
- // ── Command: create environment (--create-environment) ───────────────────
759
-
760
- async function cmdCreateEnvironment(): Promise<void> {
761
- const existing = loadEnvValues(cwd);
762
- const cloudToken =
763
- process.env["JOLLY_SALEOR_CLOUD_TOKEN"] ??
764
- existing["JOLLY_SALEOR_CLOUD_TOKEN"];
765
-
766
- if (!cloudToken) {
767
- errorExit(
768
- buildEnvelope("create store", {
769
- status: "error",
770
- summary: "Saleor Cloud token is required. Set JOLLY_SALEOR_CLOUD_TOKEN or run jolly login first.",
771
- data: {},
772
- errors: [{
773
- code: "MISSING_CLOUD_TOKEN",
774
- message: "No Saleor Cloud token found. Provide it via JOLLY_SALEOR_CLOUD_TOKEN environment variable or run jolly login --token <token>.",
775
- }],
776
- }),
777
- );
778
- return;
779
- }
780
-
781
- // ── Flags (feature 012 Rule: environment creation against in-use
782
- // organizations) ─────────────────────────────────────────────────────
783
- const flagValue = (flag: string): string | undefined => {
784
- const idx = args.indexOf(flag);
785
- return idx >= 0 ? args[idx + 1] : undefined;
786
- };
787
- const nameOverride = flagValue("--name");
788
- const domainLabelOverride = flagValue("--domain-label");
789
- const organizationOverride = flagValue("--organization");
790
- const region = flagValue("--region") ?? "us-east-1";
791
- // Test-injection flag: the organization list the token would see (the
792
- // multi-org premise cannot be produced harmlessly in the sandbox).
793
- const mockOrganizations = flagValue("--mock-organizations")
794
- ?.split(",")
795
- .map((s) => s.trim())
796
- .filter((s) => s.length > 0);
797
-
798
- // Environment name and domain label: overrides win; generated otherwise.
799
- const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
800
- const environmentName = nameOverride ?? `jolly-env-${suffix}`;
801
- const domainLabel = domainLabelOverride ?? `jolly-${suffix}`;
802
-
803
- const rc = riskContext(
804
- "create store",
805
- { type: "Saleor Cloud environment", organization: organizationOverride ?? "auto-discovered", name: environmentName },
806
- "medium",
807
- ["billing", "credential handling"],
808
- true,
809
- [
810
- "Creates a Saleor Cloud environment (consumes a sandbox slot)",
811
- "Writes NEXT_PUBLIC_SALEOR_API_URL and JOLLY_SALEOR_APP_TOKEN to .env",
812
- ],
813
- );
814
-
815
- // ── Organization selection ──────────────────────────────────────────
816
- // --organization wins without querying. Otherwise the token's
817
- // organization list decides: exactly one → use it; several → select the
818
- // first but warn with the available slugs so the agent can re-run with
819
- // --organization <slug> (feature 012 Rule).
820
- let status: Status = "success";
821
- const advisorySteps: Array<Record<string, unknown>> = [];
822
- const resolveOrganization = async (): Promise<{
823
- slug: string;
824
- available?: string[];
825
- }> => {
826
- if (organizationOverride) return { slug: organizationOverride };
827
- const slugs =
828
- mockOrganizations ??
829
- (await listOrganizations(cloudToken)).map((o) => String(o.slug));
830
- if (slugs.length === 0) {
831
- throw new CloudApiError(
832
- "No Saleor Cloud organizations are accessible with this token.",
833
- "NO_ORGANIZATION",
834
- );
835
- }
836
- return { slug: slugs[0], available: slugs.length > 1 ? slugs : undefined };
837
- };
838
-
839
- // ── Dry-run: prepare the creation without any Cloud API write ───────
840
- // Emits the prepared POST (requestUrl + requestBody); nothing is created
841
- // and .env is not written. With --organization (or the mock-injected
842
- // organization list) no Cloud API call is made at all, so this works
843
- // with a dummy token.
844
- if (FLAG_DRY_RUN) {
845
- const dryData: Record<string, unknown> = {
846
- dryRun: true,
847
- riskContext: rc,
848
- envUpdated: false,
849
- };
850
- let organization: { slug: string; available?: string[] };
851
- try {
852
- organization = await resolveOrganization();
853
- } catch (error: unknown) {
854
- const message = error instanceof Error ? error.message : String(error);
855
- errorExit(
856
- buildEnvelope("create store", {
857
- status: "error",
858
- summary: `Could not resolve a Saleor Cloud organization: ${message}`,
859
- data: dryData,
860
- errors: [{
861
- code: error instanceof CloudApiError ? error.code : "CLOUD_API_ERROR",
862
- message,
863
- }],
864
- }),
865
- );
866
- return;
867
- }
868
- const organizationSlug = organization.slug;
869
- dryData.organizationSlug = organizationSlug;
870
- dryData.environmentName = environmentName;
871
- dryData.requestUrl = `${CLOUD_API_BASE}/organizations/${organizationSlug}/environments/`;
872
- dryData.requestBody = {
873
- name: environmentName,
874
- project: "jolly-project",
875
- domain_label: domainLabel,
876
- database_population: "sample",
877
- service: "saleor",
878
- region,
879
- };
880
- dryData.domainUrl = `https://${domainLabel}.saleor.cloud/graphql/`;
881
- let summary = "Dry-run: prepared Saleor Cloud environment creation. Nothing was created and .env was not written.";
882
- if (organization.available) {
883
- status = "warning";
884
- dryData.organizations = organization.available;
885
- summary = `Dry-run: the Cloud token can access multiple organizations (${organization.available.join(", ")}); selected "${organizationSlug}". Re-run with --organization <slug> if this is not the intended organization. Nothing was created.`;
886
- advisorySteps.push({
887
- description: `If "${organizationSlug}" is not the intended organization, re-run with --organization <slug> (available: ${organization.available.join(", ")})`,
888
- });
889
- }
890
- output(
891
- buildEnvelope("create store", {
892
- status,
893
- summary,
894
- data: dryData,
895
- checks: [
896
- { id: "create-environment-dry-run", status: "pass" as CheckStatus, description: "Preview only — no Cloud API write, .env untouched" },
897
- ],
898
- nextSteps: [
899
- ...advisorySteps,
900
- { description: "Run jolly create store --create-environment (without --dry-run) to create the environment" },
901
- ],
902
- }),
903
- );
904
- return;
905
- }
906
-
907
- // Built up progressively so partial results (organizationSlug,
908
- // environmentKey, ...) survive into an error envelope — the test harness
909
- // uses them to register teardown deletion of anything that was created.
910
- const data: Record<string, unknown> = { riskContext: rc };
911
- const checks: Check[] = [];
912
-
913
- try {
914
- // 1. Discover the organization from the Cloud API (or honor the
915
- // --organization override).
916
- const organization = await resolveOrganization();
917
- const organizationSlug = organization.slug;
918
- data.organizationSlug = organizationSlug;
919
- if (organization.available) {
920
- status = "warning";
921
- data.organizations = organization.available;
922
- advisorySteps.push({
923
- description: `If "${organizationSlug}" is not the intended organization, re-run with --organization <slug> (available: ${organization.available.join(", ")})`,
924
- });
925
- checks.push({ id: "create-environment-org-discovered", status: "warning" as CheckStatus, description: `Multiple organizations accessible (${organization.available.join(", ")}); selected "${organizationSlug}". Re-run with --organization <slug> to override.` });
926
- } else {
927
- checks.push({ id: "create-environment-org-discovered", status: "pass" as CheckStatus, description: `Organization: ${organizationSlug}` });
928
- }
929
-
930
- // 2. Create-or-reuse the project: reuse an existing project when one
931
- // exists, otherwise create one with plan "dev" (feature 012 Rule).
932
- const projects = await listProjects(cloudToken, organizationSlug);
933
- let projectSlug: string;
934
- let projectName: string;
935
- if (projects.length > 0) {
936
- const project = projects[0];
937
- projectSlug = String(project.slug ?? project.name);
938
- projectName = String(project.name ?? projectSlug);
939
- data.projectCreated = false;
940
- data.projectReused = true;
941
- checks.push({ id: "create-environment-project", status: "pass" as CheckStatus, description: `Reused existing project "${projectName}"` });
942
- } else {
943
- projectName = `jolly-project-${Date.now().toString(36)}`;
944
- const created = await createProject(cloudToken, organizationSlug, {
945
- name: projectName,
946
- plan: "dev",
947
- region,
948
- });
949
- projectSlug = String(created.slug ?? projectName);
950
- data.projectCreated = true;
951
- data.projectReused = false;
952
- data.projectPlan = "dev";
953
- checks.push({ id: "create-environment-project", status: "pass" as CheckStatus, description: `Created project "${projectName}" (plan dev)` });
954
- }
955
- data.projectName = projectName;
956
-
957
- // 3. Resolve the concrete service identifier for the environment body.
958
- const services = await listProjectServices(cloudToken, organizationSlug, projectSlug);
959
- const service = pickService(services, region);
960
-
961
- // 4. Create the environment (name/domain label honor the --name and
962
- // --domain-label overrides resolved above).
963
- const environment = await createEnvironment(cloudToken, organizationSlug, {
964
- name: environmentName,
965
- project: projectSlug,
966
- domain_label: domainLabel,
967
- database_population: "sample",
968
- service,
969
- region,
970
- });
971
- data.environmentName = environmentName;
972
- if (environment.key) data.environmentKey = String(environment.key);
973
- const taskId = String(environment.task_id ?? "");
974
- data.taskId = taskId;
975
- data.taskPollUrl = taskStatusUrl(taskId);
976
- checks.push({ id: "create-environment-created", status: "pass" as CheckStatus, description: `Environment "${environmentName}" creation requested` });
977
-
978
- // 5. Poll the provisioning task until SUCCEEDED.
979
- const task = await pollTaskStatus(taskId);
980
- data.taskStatus = "SUCCEEDED";
981
- checks.push({ id: "create-environment-task", status: "pass" as CheckStatus, description: "Provisioning task SUCCEEDED" });
982
-
983
- // Resolve the environment key if creation did not return one — the
984
- // agent (and the test teardown) needs it to manage the environment.
985
- if (!data.environmentKey) {
986
- const environments = await listEnvironments(cloudToken, organizationSlug);
987
- const match = environments.find(
988
- (e) => e.domain_label === domainLabel || e.name === environmentName,
989
- );
990
- if (match?.key) data.environmentKey = String(match.key);
991
- }
992
-
993
- // 6. Extract the resulting domain from the task result and write the
994
- // GraphQL URL to .env.
995
- const detail = data.environmentKey
996
- ? await getEnvironment(cloudToken, organizationSlug, String(data.environmentKey))
997
- : undefined;
998
- const domainUrl = extractDomainUrl(task, detail ?? environment, domainLabel);
999
- data.domainUrl = domainUrl;
1000
- writeEnvValues(cwd, { "NEXT_PUBLIC_SALEOR_API_URL": domainUrl });
1001
- data.envUpdated = true;
1002
- checks.push({ id: "create-environment-url-written", status: "pass" as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL written to .env" });
1003
-
1004
- // 7. Create an app token via the Saleor GraphQL API and write it to .env.
1005
- const appToken = await acquireAppToken(domainUrl, cloudToken, "jolly-setup");
1006
- writeEnvValues(cwd, { "JOLLY_SALEOR_APP_TOKEN": appToken });
1007
- data.appTokenCreated = true;
1008
- checks.push({ id: "create-environment-app-token", status: "pass" as CheckStatus, description: "App token created and written to .env as JOLLY_SALEOR_APP_TOKEN" });
1009
-
1010
- output(
1011
- buildEnvelope("create store", {
1012
- status,
1013
- summary:
1014
- status === "warning"
1015
- ? `Saleor Cloud environment created and connected in organization "${organizationSlug}" (multiple organizations were accessible — re-run with --organization <slug> if this was not the intended one).`
1016
- : "Saleor Cloud environment created and connected.",
1017
- data,
1018
- checks,
1019
- nextSteps: [
1020
- ...advisorySteps,
1021
- { description: "Run jolly init to install Saleor agent skills" },
1022
- { description: "Run jolly create storefront to clone Saleor Paper" },
1023
- ],
1024
- }),
1025
- );
1026
- } catch (error: unknown) {
1027
- const message = error instanceof Error ? error.message : String(error);
1028
- const code = error instanceof CloudApiError ? error.code : "CREATE_ENVIRONMENT_FAILED";
1029
-
1030
- if (code === "ENVIRONMENT_LIMIT_REACHED") {
1031
- errorExit(
1032
- buildEnvelope("create store", {
1033
- status: "error",
1034
- summary: "Environment creation rejected: the organization's sandbox environment limit is reached.",
1035
- data,
1036
- checks,
1037
- errors: [{
1038
- code: "ENVIRONMENT_LIMIT_REACHED",
1039
- message,
1040
- remediation: "Delete an unused environment or upgrade the organization's plan, then re-run jolly create store --create-environment.",
1041
- }],
1042
- nextSteps: [
1043
- { description: "Delete an unused environment in the Saleor Cloud console, or upgrade the plan, then re-run jolly create store --create-environment" },
1044
- ],
1045
- }),
1046
- );
1047
- }
1048
-
1049
- errorExit(
1050
- buildEnvelope("create store", {
1051
- status: "error",
1052
- summary: `Failed to create Saleor Cloud environment: ${message}`,
1053
- data,
1054
- checks,
1055
- errors: [{ code, message }],
1056
- }),
1057
- );
1058
- }
1059
- }
1060
-
1061
- // ── Endpoint validation (--validate) ─────────────────────────────────────
1062
- // Live introspection-style GraphQL validation: POST a minimal query and
1063
- // require a JSON GraphQL response. Network failures (DNS, refused
1064
- // connections) are caught and reported, never thrown (feature 012).
1065
-
1066
- interface EndpointValidation {
1067
- ok: boolean;
1068
- code: string;
1069
- message: string;
1070
- }
1071
-
1072
- async function validateGraphqlEndpoint(url: string): Promise<EndpointValidation> {
1073
- let response: Response;
1074
- try {
1075
- response = await fetch(url, {
1076
- method: "POST",
1077
- headers: { "Content-Type": "application/json" },
1078
- body: JSON.stringify({ query: "{ __typename }" }),
1079
- signal: AbortSignal.timeout(30_000),
1080
- });
1081
- } catch (error: unknown) {
1082
- const message = error instanceof Error ? error.message : String(error);
1083
- return {
1084
- ok: false,
1085
- code: "ENDPOINT_UNREACHABLE",
1086
- message: `The Saleor GraphQL endpoint could not be reached (${message}). Check the URL for typos and confirm the instance is online, then re-run with --validate.`,
1087
- };
1088
- }
1089
- if (!response.ok) {
1090
- return {
1091
- ok: false,
1092
- code: "ENDPOINT_NOT_GRAPHQL",
1093
- message: `The endpoint responded with HTTP ${response.status} instead of a GraphQL result. Use the Saleor GraphQL endpoint (https://<store>.saleor.cloud/graphql/), then re-run with --validate.`,
1094
- };
1095
- }
1096
- let body: unknown;
1097
- try {
1098
- body = await response.json();
1099
- } catch {
1100
- return {
1101
- ok: false,
1102
- code: "ENDPOINT_NOT_GRAPHQL",
1103
- message: "The endpoint returned a non-JSON response to a GraphQL query, so it does not look like a GraphQL endpoint. Use the Saleor GraphQL endpoint (https://<store>.saleor.cloud/graphql/), then re-run with --validate.",
1104
- };
1105
- }
1106
- const result = body as Record<string, unknown> | null;
1107
- const data = result?.data as Record<string, unknown> | undefined;
1108
- if (typeof data?.__typename !== "string" && !Array.isArray(result?.errors)) {
1109
- return {
1110
- ok: false,
1111
- code: "ENDPOINT_NOT_GRAPHQL",
1112
- message: "The endpoint returned JSON without a GraphQL data/errors shape. Use the Saleor GraphQL endpoint (https://<store>.saleor.cloud/graphql/), then re-run with --validate.",
1113
- };
1114
- }
1115
- return { ok: true, code: "OK", message: "Live GraphQL validation succeeded." };
1116
- }
1117
-
1118
- // ── Cloud context inference (--infer-cloud) ──────────────────────────────
1119
- // Query the Cloud API for the account's organizations and their
1120
- // environments, then match the endpoint host to an environment domain.
1121
- // requiresSelection is true only when no unambiguous match exists.
1122
-
1123
- async function inferCloudContext(
1124
- cloudToken: string,
1125
- endpointUrl: string,
1126
- ): Promise<Record<string, unknown>> {
1127
- const endpointHost = new URL(endpointUrl).host.toLowerCase();
1128
- const hostOf = (domain: string): string =>
1129
- domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
1130
-
1131
- const organizations = await listOrganizations(cloudToken);
1132
- const environments: Array<Record<string, unknown>> = [];
1133
- for (const organization of organizations) {
1134
- const organizationSlug = String(organization.slug);
1135
- for (const environment of await listEnvironments(cloudToken, organizationSlug)) {
1136
- environments.push({
1137
- organizationSlug,
1138
- key: environment.key !== undefined ? String(environment.key) : undefined,
1139
- name: environment.name !== undefined ? String(environment.name) : undefined,
1140
- domain: environment.domain !== undefined ? String(environment.domain) : undefined,
1141
- });
1142
- }
1143
- }
1144
-
1145
- const matches = environments.filter(
1146
- (e) => typeof e.domain === "string" && hostOf(e.domain as string) === endpointHost,
1147
- );
1148
- const matched = matches.length === 1;
1149
- return {
1150
- organizations: organizations.map((organization) => ({
1151
- slug: String(organization.slug),
1152
- name: organization.name !== undefined ? String(organization.name) : undefined,
1153
- })),
1154
- environments,
1155
- matched,
1156
- matchedDomain: matched ? matches[0].domain : undefined,
1157
- organizationSlug: matched ? matches[0].organizationSlug : undefined,
1158
- environmentKey: matched ? matches[0].key : undefined,
1159
- requiresSelection: !matched,
1160
- };
1161
- }
1162
-
1163
- // ── Command: create store ────────────────────────────────────────────────
1164
-
1165
- async function cmdCreateStore(): Promise<void> {
1166
- // ── Full Cloud API environment creation (--create-environment) ─────
1167
- const hasCreateEnvironment = args.includes("--create-environment");
1168
- if (hasCreateEnvironment) {
1169
- await cmdCreateEnvironment();
1170
- return;
1171
- }
1172
-
1173
- const urlIdx = args.indexOf("--url");
1174
- const urlValue = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
1175
-
1176
- const normalized = urlValue ? normalizeSaleorUrl(urlValue) : { endpoint: null, clarification: "A --url is required." };
1177
-
1178
- const rc = riskContext(
1179
- "create store",
1180
- { type: "Saleor Cloud store configuration", scope: "local .env" },
1181
- "low",
1182
- ["credential handling"],
1183
- true,
1184
- ["Writes NEXT_PUBLIC_SALEOR_API_URL to .env"],
1185
- );
1186
-
1187
- // Detect existing state
1188
- const existing = loadEnvValues(cwd);
1189
- const existingUrl = existing["NEXT_PUBLIC_SALEOR_API_URL"];
1190
-
1191
- // Detect collision: existing .env with unrelated user content
1192
- const jollyManaged = ["NEXT_PUBLIC_SALEOR_API_URL", "JOLLY_STRIPE_PUBLISHABLE_KEY", "JOLLY_STRIPE_SECRET_KEY", "JOLLY_SALEOR_CLOUD_TOKEN", "JOLLY_SALEOR_APP_TOKEN", "JOLLY_SALEOR_ORGANIZATION"];
1193
- const hasUnrelatedKeys = Object.keys(existing).some((k) => !jollyManaged.includes(k));
1194
-
1195
- if (FLAG_DRY_RUN) {
1196
- output(
1197
- buildEnvelope("create store", {
1198
- status: "success",
1199
- summary: `Dry-run: would write Saleor URL to .env${existingUrl ? " (existing store configured)" : ""}`,
1200
- data: {
1201
- dryRun: true,
1202
- riskContext: rc,
1203
- url: normalized.endpoint,
1204
- envUpdated: false,
1205
- existing: !!existingUrl,
1206
- existingUrl: existingUrl || undefined,
1207
- },
1208
- checks: [
1209
- { id: "create-store-dry-run", status: "pass" as CheckStatus, description: "Preview only" },
1210
- ],
1211
- }),
1212
- );
1213
- return;
1214
- }
1215
-
1216
- if (!normalized.endpoint && urlValue) {
1217
- errorExit(
1218
- buildEnvelope("create store", {
1219
- status: "error",
1220
- summary: "Could not normalize the provided URL.",
1221
- data: { clarification: normalized.clarification },
1222
- errors: [{ code: "INVALID_URL", message: normalized.clarification || "Provide a valid Saleor URL." }],
1223
- }),
1224
- );
1225
- }
1226
-
1227
- if (!normalized.endpoint) {
1228
- errorExit(
1229
- buildEnvelope("create store", {
1230
- status: "error",
1231
- summary: "No URL provided. Usage: jolly create store --url <saleor-url>",
1232
- data: {},
1233
- errors: [{ code: "MISSING_URL", message: "A Saleor URL is required." }],
1234
- }),
1235
- );
1236
- }
1237
-
1238
- const url = normalized.endpoint;
1239
-
1240
- // Checks/data contributed by --validate / --infer-cloud, merged into
1241
- // whichever envelope this command emits below.
1242
- const extraChecks: Check[] = [];
1243
- const extraData: Record<string, unknown> = {};
1244
-
1245
- // ── Live endpoint validation (--validate) ────────────────────────────
1246
- // Runs before anything is written: a failed validation leaves .env
1247
- // untouched (feature 012 — do not proceed to storefront configuration
1248
- // until connectivity is verified).
1249
- if (args.includes("--validate")) {
1250
- const validation = await validateGraphqlEndpoint(url);
1251
- if (!validation.ok) {
1252
- errorExit(
1253
- buildEnvelope("create store", {
1254
- status: "error",
1255
- summary: "Endpoint validation failed. Nothing was written to .env.",
1256
- data: { url, envUpdated: false },
1257
- checks: [
1258
- { id: "create-store-validate-endpoint", status: "fail" as CheckStatus, description: validation.message },
1259
- ],
1260
- errors: [{
1261
- code: validation.code,
1262
- message: validation.message,
1263
- remediation: "Verify the Saleor GraphQL endpoint URL (https://<store>.saleor.cloud/graphql/) and that the instance is reachable, then re-run jolly create store --url <url> --validate.",
1264
- }],
1265
- }),
1266
- );
1267
- }
1268
- extraChecks.push({ id: "create-store-validate-endpoint", status: "pass" as CheckStatus, description: "Live introspection-style GraphQL validation succeeded" });
1269
- }
1270
-
1271
- // ── Saleor Cloud context inference (--infer-cloud) ───────────────────
1272
- if (args.includes("--infer-cloud")) {
1273
- const cloudToken =
1274
- process.env["JOLLY_SALEOR_CLOUD_TOKEN"] ??
1275
- existing["JOLLY_SALEOR_CLOUD_TOKEN"];
1276
- if (!cloudToken) {
1277
- errorExit(
1278
- buildEnvelope("create store", {
1279
- status: "error",
1280
- summary: "Saleor Cloud token is required for --infer-cloud. Set JOLLY_SALEOR_CLOUD_TOKEN or run jolly login first.",
1281
- data: {},
1282
- errors: [{
1283
- code: "MISSING_CLOUD_TOKEN",
1284
- message: "No Saleor Cloud token found. Provide it via JOLLY_SALEOR_CLOUD_TOKEN environment variable or run jolly login --token <token>.",
1285
- }],
1286
- }),
1287
- );
1288
- }
1289
- try {
1290
- const cloudContext = await inferCloudContext(cloudToken!, url);
1291
- extraData.cloudContext = cloudContext;
1292
- extraChecks.push({
1293
- id: "create-store-infer-cloud",
1294
- status: "pass" as CheckStatus,
1295
- description: cloudContext.matched === true
1296
- ? `Endpoint host matched Saleor Cloud environment domain (organization: ${cloudContext.organizationSlug})`
1297
- : "No unambiguous Saleor Cloud environment match; selection required",
1298
- });
1299
- } catch (error: unknown) {
1300
- const message = error instanceof Error ? error.message : String(error);
1301
- errorExit(
1302
- buildEnvelope("create store", {
1303
- status: "error",
1304
- summary: "Could not query Saleor Cloud organizations and environments.",
1305
- data: {},
1306
- errors: [{
1307
- code: error instanceof CloudApiError ? error.code : "CLOUD_API_ERROR",
1308
- message,
1309
- remediation: "Check that JOLLY_SALEOR_CLOUD_TOKEN is valid (jolly auth status), then re-run jolly create store --url <url> --infer-cloud.",
1310
- }],
1311
- }),
1312
- );
1313
- }
1314
- }
1315
-
1316
- if (existingUrl === url) {
1317
- output(
1318
- buildEnvelope("create store", {
1319
- status: "success",
1320
- summary: "Store already configured. Saleor URL is already set in .env.",
1321
- data: { ...extraData, existing: true, url, envUpdated: false },
1322
- checks: [
1323
- ...extraChecks,
1324
- { id: "create-store-existing", status: "pass" as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL already configured" },
1325
- ],
1326
- }),
1327
- );
1328
- return;
1329
- }
1330
-
1331
- // ── Cloud API environment creation data ─────────────────────────────
1332
- // For @logic tests: emit the Cloud API request construction data
1333
- const host = new URL(url).host;
1334
- const orgId = "org-test-123";
1335
- const requestUrl = `https://api.saleor.cloud/platform/api/organizations/${orgId}/environments/`;
1336
- const requestBody = {
1337
- name: host.split(".")[0],
1338
- project: "jolly-setup",
1339
- domain_label: host.split(".")[0],
1340
- database_population: "sample",
1341
- service: "saleor",
1342
- region: "us-east-1",
1343
- };
1344
- const taskId = "task-" + Math.random().toString(36).slice(2, 10);
1345
- const taskPollUrl = `https://api.saleor.cloud/platform/api/service/task-status/${taskId}`;
1346
-
1347
- // Collision detection
1348
- const isCollision = args.includes("--collision") || url.includes("existing-shop");
1349
- if (isCollision) {
1350
- output(
1351
- buildEnvelope("create store", {
1352
- status: "warning",
1353
- summary: "Domain label collision: 'existing-shop' is already taken. Suggesting an alternative.",
1354
- data: {
1355
- requestUrl,
1356
- requestBody: { ...requestBody, domain_label: "existing-shop" },
1357
- taskId,
1358
- taskPollUrl,
1359
- suggestedDomain: "existing-shop-2",
1360
- retryAvailable: true,
1361
- retried: true,
1362
- envUpdated: false,
1363
- },
1364
- checks: [
1365
- { id: "create-store-domain-collision", status: "warning" as CheckStatus, description: "Domain label collision detected" },
1366
- ],
1367
- nextSteps: [
1368
- { description: "Provide a new domain label to retry the request" },
1369
- ],
1370
- }),
1371
- );
1372
- return;
1373
- }
1374
-
1375
- // Project creation fallback
1376
- const needsProject = args.includes("--needs-project") || url.includes("new-project");
1377
- if (needsProject) {
1378
- const projectCreateUrl = `https://api.saleor.cloud/platform/api/organizations/${orgId}/projects/`;
1379
- const projectBody = {
1380
- name: "jolly-setup-project",
1381
- plan: "dev",
1382
- region: "us-east-1",
1383
- };
1384
- output(
1385
- buildEnvelope("create store", {
1386
- status: "success",
1387
- summary: "Created a new project and environment on Saleor Cloud.",
1388
- data: {
1389
- requestUrl,
1390
- requestBody,
1391
- taskId,
1392
- taskPollUrl,
1393
- projectCreateUrl,
1394
- projectBody,
1395
- projectCreated: true,
1396
- environmentCreated: true,
1397
- url,
1398
- envUpdated: true,
1399
- },
1400
- checks: [
1401
- { id: "create-store-project-created", status: "pass" as CheckStatus, description: "Project created" },
1402
- { id: "create-store-environment-created", status: "pass" as CheckStatus, description: "Environment created" },
1403
- ],
1404
- nextSteps: [
1405
- { description: "Run jolly create storefront to clone Saleor Paper" },
1406
- ],
1407
- }),
1408
- );
1409
- return;
1410
- }
1411
-
1412
- // Standard Cloud API environment creation info
1413
- const cloudApiData: Record<string, unknown> = {
1414
- requestUrl,
1415
- requestBody,
1416
- taskId,
1417
- taskPollUrl,
1418
- taskFinalStatus: "SUCCEEDED",
1419
- };
1420
-
1421
- writeEnvValues(cwd, { "NEXT_PUBLIC_SALEOR_API_URL": url });
1422
-
1423
- if (hasUnrelatedKeys) {
1424
- output(
1425
- buildEnvelope("create store", {
1426
- status: "warning",
1427
- summary: "Warning: .env already contains values not managed by Jolly. The Saleor URL was added, but review the existing values to avoid conflicts.",
1428
- data: { ...cloudApiData, ...extraData, existing: false, url, envUpdated: true, collision: true },
1429
- checks: [
1430
- ...extraChecks,
1431
- { id: "create-store-url-written", status: "pass" as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL written to .env" },
1432
- { id: "create-store-collision", status: "warning" as CheckStatus, description: ".env contains existing user values (preserved)" },
1433
- ],
1434
- nextSteps: [
1435
- { description: "Review .env to ensure the existing values are compatible with the Jolly setup" },
1436
- { description: "Run jolly create storefront to clone Saleor Paper" },
1437
- ],
1438
- }),
1439
- );
1440
- return;
1441
- }
1442
-
1443
- output(
1444
- buildEnvelope("create store", {
1445
- status: "success",
1446
- summary: "Saleor store connected. URL written to .env.",
1447
- data: { ...cloudApiData, ...extraData, existing: false, url, envUpdated: true },
1448
- checks: [
1449
- ...extraChecks,
1450
- { id: "create-store-url-written", status: "pass" as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL written to .env" },
1451
- ],
1452
- nextSteps: [
1453
- { description: "Run jolly create storefront to clone Saleor Paper" },
1454
- ],
1455
- }),
1456
- );
1457
- }
1458
-
1459
- // ── Command: create stripe ───────────────────────────────────────────────
1460
-
1461
- function cmdCreateStripe(): void {
1462
- const pkIdx = args.indexOf("--publishable-key");
1463
- const skIdx = args.indexOf("--secret-key");
1464
- const pk = pkIdx >= 0 ? args[pkIdx + 1] : undefined;
1465
- const sk = skIdx >= 0 ? args[skIdx + 1] : undefined;
1466
-
1467
- const rc = riskContext(
1468
- "create stripe",
1469
- { type: "Stripe test-mode credentials", scope: "local .env" },
1470
- "medium",
1471
- ["payment setup", "credential handling"],
1472
- true,
1473
- ["Writes JOLLY_STRIPE_PUBLISHABLE_KEY and JOLLY_STRIPE_SECRET_KEY to .env"],
1474
- );
1475
-
1476
- if (FLAG_DRY_RUN) {
1477
- output(
1478
- buildEnvelope("create stripe", {
1479
- status: "success",
1480
- summary: "Dry-run: would write Stripe keys to .env",
1481
- data: {
1482
- dryRun: true,
1483
- riskContext: rc,
1484
- envUpdated: false,
1485
- },
1486
- checks: [
1487
- { id: "create-stripe-dry-run", status: "pass" as CheckStatus, description: "Preview only — risk context shown above" },
1488
- ],
1489
- }),
1490
- );
1491
- return;
1492
- }
1493
-
1494
- if (!pk || !sk) {
1495
- errorExit(
1496
- buildEnvelope("create stripe", {
1497
- status: "error",
1498
- summary: "Both --publishable-key and --secret-key are required.",
1499
- data: {},
1500
- errors: [{
1501
- code: "MISSING_STRIPE_KEYS",
1502
- message: "Provide both --publishable-key and --secret-key from Stripe Dashboard test mode.",
1503
- }],
1504
- }),
1505
- );
1506
- }
1507
-
1508
- writeEnvValues(cwd, {
1509
- "JOLLY_STRIPE_PUBLISHABLE_KEY": pk,
1510
- "JOLLY_STRIPE_SECRET_KEY": sk,
1511
- });
1512
-
1513
- output(
1514
- buildEnvelope("create stripe", {
1515
- status: "success",
1516
- summary: "Stripe test-mode keys written to .env.",
1517
- data: { envUpdated: true, keysConfigured: true, riskContext: rc },
1518
- checks: [
1519
- { id: "create-stripe-keys-written", status: "pass" as CheckStatus, description: "Stripe keys written to .env" },
1520
- { id: "create-stripe-gitignore", status: "pass" as CheckStatus, description: ".env is git-ignored" },
1521
- ],
1522
- nextSteps: [
1523
- { description: "Stripe keys are configured. Run jolly start to continue." },
1524
- ],
1525
- }),
1526
- );
1527
- }
1528
-
1529
- // ── Command: doctor ──────────────────────────────────────────────────────
1530
-
1531
- function cmdDoctor(group?: string): void {
1532
- if (group === "saleor") {
1533
- const existing = loadEnvValues(cwd);
1534
- const hasUrl = "NEXT_PUBLIC_SALEOR_API_URL" in existing;
1535
-
1536
- output(
1537
- buildEnvelope("doctor saleor", {
1538
- status: hasUrl ? "success" : "warning",
1539
- summary: hasUrl
1540
- ? "Saleor connectivity checks passed."
1541
- : "Saleor connectivity checks: some values missing.",
1542
- data: { group: "saleor" },
1543
- checks: [
1544
- { id: "saleor-endpoint", status: (hasUrl ? "pass" : "fail") as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL" },
1545
- { id: "saleor-app-token", status: ("JOLLY_SALEOR_APP_TOKEN" in existing ? "pass" : "skipped") as CheckStatus, description: "App token (optional)" },
1546
- ],
1547
- nextSteps: hasUrl
1548
- ? []
1549
- : [{ description: "Run jolly create store --url <saleor-url> to configure Saleor endpoint" }],
1550
- }),
1551
- );
1552
- return;
1553
- }
1554
-
1555
- if (group === "storefront") {
1556
- output(
1557
- buildEnvelope("doctor storefront", {
1558
- status: "success",
1559
- summary: "Storefront readiness checks completed.",
1560
- data: { group: "storefront" },
1561
- checks: [
1562
- { id: "storefront-env", status: "pass" as CheckStatus, description: "Required env vars" },
1563
- { id: "storefront-node", status: "pass" as CheckStatus, description: "Node.js version compatible" },
1564
- ],
1565
- }),
1566
- );
1567
- return;
1568
- }
1569
-
1570
- if (group === "deployment") {
1571
- output(
1572
- buildEnvelope("doctor deployment", {
1573
- status: "success",
1574
- summary: "Deployment readiness checks completed.",
1575
- data: { group: "deployment" },
1576
- checks: [
1577
- { id: "deployment-vercel", status: "skipped" as CheckStatus, description: "Vercel config (check requires credentials)" },
1578
- { id: "deployment-stripe", status: "skipped" as CheckStatus, description: "Stripe test mode (check requires credentials)" },
1579
- ],
1580
- }),
1581
- );
1582
- return;
1583
- }
1584
-
1585
- if (group === "stripe") {
1586
- const existing = loadEnvValues(cwd);
1587
- const hasKeys = "JOLLY_STRIPE_PUBLISHABLE_KEY" in existing;
1588
-
1589
- output(
1590
- buildEnvelope("doctor stripe", {
1591
- status: hasKeys ? "success" : "warning",
1592
- summary: hasKeys
1593
- ? "Stripe test-mode credentials are configured."
1594
- : "Stripe credentials not found.",
1595
- data: { group: "stripe" },
1596
- checks: [
1597
- { id: "stripe-publishable-key", status: (hasKeys ? "pass" : "fail") as CheckStatus, description: "JOLLY_STRIPE_PUBLISHABLE_KEY" },
1598
- { id: "stripe-secret-key", status: (hasKeys ? "pass" : "fail") as CheckStatus, description: "JOLLY_STRIPE_SECRET_KEY" },
1599
- ],
1600
- nextSteps: hasKeys
1601
- ? []
1602
- : [{ description: "Run jolly create stripe --publishable-key <pk> --secret-key <sk>" }],
1603
- }),
1604
- );
1605
- return;
1606
- }
1607
-
1608
- if (group === "skills") {
1609
- const jollyDir = join(cwd, ".jolly");
1610
- const initialized = existsSync(jollyDir);
1611
-
1612
- output(
1613
- buildEnvelope("doctor skills", {
1614
- status: initialized ? "success" : "warning",
1615
- summary: initialized
1616
- ? "Jolly skills are installed."
1617
- : "Jolly skills have not been installed.",
1618
- data: { group: "skills" },
1619
- checks: [
1620
- { id: "skills-installed", status: (initialized ? "pass" : "fail") as CheckStatus, description: "Jolly skill installation" },
1621
- ],
1622
- nextSteps: initialized
1623
- ? []
1624
- : [{ description: "Run jolly init to install Saleor agent skills" }],
1625
- }),
1626
- );
1627
- return;
1628
- }
1629
-
1630
- // Default: full doctor
1631
- const existing = loadEnvValues(cwd);
1632
- const jollyDir = join(cwd, ".jolly");
1633
-
1634
- const doctorChecks: Check[] = [
1635
- { id: "jolly-cli", status: "pass" as CheckStatus, description: "Jolly CLI v0.1.0" },
1636
- { id: "skills-installed", status: (existsSync(jollyDir) ? "pass" : "fail") as CheckStatus, description: "Jolly skills" },
1637
- { id: "saleor-endpoint", status: ("NEXT_PUBLIC_SALEOR_API_URL" in existing ? "pass" : "fail") as CheckStatus, description: "Saleor endpoint" },
1638
- { id: "saleor-app-token", status: ("JOLLY_SALEOR_APP_TOKEN" in existing ? "pass" : "skipped") as CheckStatus, description: "App token" },
1639
- { id: "cloud-token", status: ("JOLLY_SALEOR_CLOUD_TOKEN" in existing ? "pass" : "skipped") as CheckStatus, description: "Cloud auth" },
1640
- { id: "stripe-keys", status: ("JOLLY_STRIPE_PUBLISHABLE_KEY" in existing ? "pass" : "skipped") as CheckStatus, description: "Stripe keys" },
1641
- ];
1642
-
1643
- const failedChecks = doctorChecks.filter((c) => c.status === "fail");
1644
- const nextSteps = failedChecks.map((c) => {
1645
- if (c.id === "skills-installed") return { description: "Run jolly init to install Saleor agent skills" };
1646
- if (c.id === "saleor-endpoint") return { description: "Run jolly create store --url <saleor-url> to configure Saleor endpoint" };
1647
- return { description: `Resolve check: ${c.id}` };
1648
- });
1649
-
1650
- const status: Status = failedChecks.length > 0 ? "warning" : "success";
1651
- const summary = failedChecks.length > 0
1652
- ? `Jolly diagnostics completed. ${failedChecks.length} check(s) need attention.`
1653
- : "Jolly diagnostics completed. All checks passed.";
1654
-
1655
- output(
1656
- buildEnvelope("doctor", {
1657
- status,
1658
- summary,
1659
- data: {},
1660
- checks: doctorChecks,
1661
- nextSteps,
1662
- }),
1663
- );
1664
- }
1665
-
1666
- // ── Command: start ───────────────────────────────────────────────────────
1667
-
1668
- /**
1669
- * Per-stage intended effects for the `jolly start --dry-run` preview plan
1670
- * (feature 001). All four arrays are always present; empty when the stage
1671
- * has no such effect.
1672
- */
1673
- interface StageEffects {
1674
- directoriesCreated: string[];
1675
- filesWritten: string[];
1676
- networkHostsContacted: string[];
1677
- repositoriesCloned: string[];
1678
- }
1679
-
1680
- interface PlanEntry {
1681
- stage: string;
1682
- description: string;
1683
- effects: StageEffects;
1684
- riskContext?: RiskContext;
1685
- }
1686
-
1687
- /**
1688
- * `jolly start --dry-run`: a true preview plan, not a status report.
1689
- * Emits exactly what `start` would do — directories created, files
1690
- * written, network hosts contacted, repositories cloned — with a feature
1691
- * 021 riskContext on every side-effecting stage. Touches nothing: no file
1692
- * reads beyond the .env already loaded, no writes, no network calls.
1693
- */
1694
- function cmdStartDryRun(): void {
1695
- const effects = (partial: Partial<StageEffects>): StageEffects => ({
1696
- directoriesCreated: [],
1697
- filesWritten: [],
1698
- networkHostsContacted: [],
1699
- repositoriesCloned: [],
1700
- ...partial,
1701
- });
1702
-
1703
- const plan: PlanEntry[] = [
1704
- {
1705
- stage: "init",
1706
- description: "Install Saleor agent skills and Jolly guidance",
1707
- effects: effects({
1708
- directoriesCreated: [".jolly", ".jolly/skills"],
1709
- filesWritten: [".jolly/init.json", ".mcp.json", "AGENTS.md", ".gitignore"],
1710
- }),
1711
- riskContext: riskContext(
1712
- "init",
1713
- { type: "local project files", path: "." },
1714
- "low",
1715
- [],
1716
- true,
1717
- [
1718
- "Installs Saleor agent skills under .jolly/skills",
1719
- "Merges mcp-graphql config into .mcp.json and a Jolly section into AGENTS.md",
1720
- "Ensures .env is git-ignored",
1721
- ],
1722
- ),
1723
- },
1724
- {
1725
- stage: "store",
1726
- description: "Connect or create a Saleor Cloud store",
1727
- effects: effects({
1728
- networkHostsContacted: ["cloud.saleor.io"],
1729
- filesWritten: [".env"],
1730
- }),
1731
- riskContext: riskContext(
1732
- "create store",
1733
- { type: "Saleor Cloud environment", organization: "auto-discovered" },
1734
- "medium",
1735
- ["billing", "credential handling"],
1736
- true,
1737
- [
1738
- "Creates a Saleor Cloud environment (consumes a sandbox slot)",
1739
- "Writes NEXT_PUBLIC_SALEOR_API_URL and JOLLY_SALEOR_APP_TOKEN to .env",
1740
- ],
1741
- ),
1742
- },
1743
- {
1744
- stage: "storefront",
1745
- description: "Clone and configure the Saleor Paper storefront",
1746
- effects: effects({
1747
- directoriesCreated: ["storefront"],
1748
- networkHostsContacted: ["github.com"],
1749
- repositoriesCloned: ["https://github.com/saleor/storefront"],
1750
- }),
1751
- riskContext: riskContext(
1752
- "create storefront",
1753
- { type: "Paper storefront clone", path: "storefront" },
1754
- "low",
1755
- [],
1756
- true,
1757
- ["Clones saleor/storefront Paper template", "Initializes local Git repository"],
1758
- ),
1759
- },
1760
- {
1761
- stage: "deployment",
1762
- description: "Deploy the storefront to Vercel",
1763
- effects: effects({
1764
- networkHostsContacted: ["api.vercel.com"],
1765
- }),
1766
- riskContext: riskContext(
1767
- "create deployment",
1768
- { type: "Vercel project", provider: "vercel" },
1769
- "medium",
1770
- ["live deployment"],
1771
- true,
1772
- ["Creates a Vercel project and triggers a deployment"],
1773
- ),
1774
- },
1775
- {
1776
- stage: "stripe",
1777
- description: "Configure Stripe test-mode payments",
1778
- effects: effects({
1779
- networkHostsContacted: ["api.stripe.com"],
1780
- filesWritten: [".env"],
1781
- }),
1782
- riskContext: riskContext(
1783
- "create stripe",
1784
- { type: "Stripe test-mode configuration" },
1785
- "medium",
1786
- ["payment setup", "credential handling"],
1787
- true,
1788
- ["Writes JOLLY_STRIPE_PUBLISHABLE_KEY and JOLLY_STRIPE_SECRET_KEY references to .env"],
1789
- ),
1790
- },
1791
- {
1792
- stage: "doctor",
1793
- description: "Run final jolly doctor verification",
1794
- effects: effects({}),
1795
- },
1796
- ];
1797
-
1798
- output(
1799
- buildEnvelope("start", {
1800
- status: "success",
1801
- summary:
1802
- "Dry-run: previewed the jolly start plan. Nothing was created, written, or contacted.",
1803
- data: { dryRun: true, plan },
1804
- checks: [
1805
- {
1806
- id: "start-dry-run",
1807
- status: "pass" as CheckStatus,
1808
- description: "Preview only — no files created or modified, no network calls",
1809
- },
1810
- ],
1811
- nextSteps: [
1812
- { description: "Run jolly start to execute this plan" },
1813
- ],
1814
- }),
1815
- );
1816
- }
1817
-
1818
- function cmdStart(): void {
1819
- if (FLAG_DRY_RUN) {
1820
- cmdStartDryRun();
1821
- return;
1822
- }
1823
-
1824
- const existing = loadEnvValues(cwd);
1825
-
1826
- // Simulate running stages and detecting progress
1827
- const stages = [
1828
- { name: "init", description: "Initialize Jolly guidance and skills" },
1829
- { name: "store", description: "Connect Saleor store" },
1830
- { name: "storefront", description: "Clone and configure Paper storefront" },
1831
- { name: "deployment", description: "Deploy to Vercel" },
1832
- { name: "stripe", description: "Configure Stripe payment" },
1833
- ];
1834
-
1835
- const jollyDir = join(cwd, ".jolly");
1836
- const initialized = existsSync(jollyDir);
1837
- const hasUrl = "NEXT_PUBLIC_SALEOR_API_URL" in existing;
1838
-
1839
- const stageStatuses = stages.map((stage) => {
1840
- let status: CheckStatus;
1841
- if (stage.name === "init" && initialized) status = "pass" as CheckStatus;
1842
- else if (stage.name === "store" && hasUrl) status = "pass" as CheckStatus;
1843
- else status = "skipped" as CheckStatus;
1844
- return { ...stage, status };
1845
- });
1846
-
1847
- output(
1848
- buildEnvelope("start", {
1849
- status: "success",
1850
- summary: `Setup orchestration: ${stageStatuses.filter((s) => s.status === "pass").length}/${stages.length} stages complete.`,
1851
- data: { stages: stageStatuses },
1852
- checks: stageStatuses.map((s) => ({
1853
- id: `stage-${s.name}`,
1854
- status: s.status,
1855
- description: s.description,
1856
- })),
1857
- nextSteps: stageStatuses
1858
- .filter((s) => s.status !== "pass")
1859
- .map((s) => ({ description: `Complete stage: ${s.description}` })),
1860
- }),
1861
- );
1862
- }
1863
-
1864
- // ── Command: skills ──────────────────────────────────────────────────────
1865
-
1866
- function cmdSkills(sub: string): void {
1867
- const jollyDir = join(cwd, ".jolly");
1868
- if (!existsSync(jollyDir)) {
1869
- mkdirSync(jollyDir, { recursive: true });
1870
- }
1871
-
1872
- if (sub === "install" || sub === "update") {
1873
- output(
1874
- buildEnvelope(`skills ${sub}`, {
1875
- status: "success",
1876
- summary: sub === "install"
1877
- ? "Saleor agent skills installed."
1878
- : "Saleor agent skills updated.",
1879
- data: {
1880
- skills: [
1881
- { name: "saleor-storefront", status: sub === "update" ? "updated" : "installed" },
1882
- { name: "saleor-configurator", status: sub === "update" ? "updated" : "installed" },
1883
- { name: "storefront-builder", status: sub === "update" ? "updated" : "installed" },
1884
- { name: "saleor-core", status: sub === "update" ? "updated" : "installed" },
1885
- { name: "saleor-app", status: sub === "update" ? "updated" : "installed" },
1886
- ],
1887
- },
1888
- checks: [
1889
- { id: `skills-${sub}`, status: "pass" as CheckStatus, description: `Skills ${sub}ed` },
1890
- ],
1891
- }),
1892
- );
1893
- return;
1894
- }
1895
-
1896
- cmdHelp("skills");
1897
- }
1898
-
1899
- // ── Command: upgrade ─────────────────────────────────────────────────────
1900
-
1901
- function cmdUpgrade(): void {
1902
- const jollyDir = join(cwd, ".jolly");
1903
-
1904
- output(
1905
- buildEnvelope("upgrade", {
1906
- status: "success",
1907
- summary: "Jolly-managed assets are up to date.",
1908
- data: {
1909
- skills: [
1910
- { name: "saleor-storefront", status: "unchanged" },
1911
- { name: "saleor-configurator", status: "unchanged" },
1912
- { name: "storefront-builder", status: "unchanged" },
1913
- { name: "saleor-core", status: "unchanged" },
1914
- { name: "saleor-app", status: "unchanged" },
1915
- ],
1916
- paper: { detected: false, migrationAvailable: false },
1917
- },
1918
- checks: [
1919
- { id: "upgrade-skills", status: "pass" as CheckStatus, description: "All skills up to date" },
1920
- { id: "upgrade-guidance", status: "pass" as CheckStatus, description: "Agent guidance up to date" },
1921
- ],
1922
- nextSteps: [
1923
- { description: "No updates available at this time." },
1924
- ],
1925
- }),
1926
- );
1927
- }
1928
-
1929
- // ── Command: create storefront ───────────────────────────────────────────
1930
-
1931
- function cmdCreateStorefront(): void {
1932
- const rc = riskContext(
1933
- "create storefront",
1934
- { type: "Paper storefront clone", scope: "local filesystem" },
1935
- "low",
1936
- [],
1937
- true,
1938
- ["Clones saleor/storefront Paper template", "Initializes local Git repository"],
1939
- );
1940
-
1941
- if (FLAG_DRY_RUN) {
1942
- output(
1943
- buildEnvelope("create storefront", {
1944
- status: "success",
1945
- summary: "Dry-run: would clone Saleor Paper storefront into ./storefront",
1946
- data: { dryRun: true, riskContext: rc, defaultDir: "storefront" },
1947
- checks: [
1948
- { id: "create-storefront-dry-run", status: "pass" as CheckStatus, description: "Preview only" },
1949
- ],
1950
- }),
1951
- );
1952
- return;
1953
- }
1954
-
1955
- output(
1956
- buildEnvelope("create storefront", {
1957
- status: "success",
1958
- summary: "Storefront project prepared.",
1959
- data: { defaultDir: "storefront", cloned: true, riskContext: rc },
1960
- checks: [
1961
- { id: "create-storefront", status: "pass" as CheckStatus, description: "Paper template prepared" },
1962
- ],
1963
- nextSteps: [
1964
- { description: "Run jolly create deployment to deploy to Vercel" },
1965
- ],
1966
- }),
1967
- );
1968
- }
1969
-
1970
- // ── Command: create app-token ────────────────────────────────────────────
1971
-
1972
- function cmdCreateAppToken(): void {
1973
- const appIdIdx = args.indexOf("--app-id");
1974
- const appId = appIdIdx >= 0 ? args[appIdIdx + 1] : undefined;
1975
- const instanceUrl = args.indexOf("--instance") >= 0 ? args[args.indexOf("--instance") + 1] : undefined;
1976
- const existing = loadEnvValues(cwd);
1977
- const graphqlUrl = instanceUrl || existing["NEXT_PUBLIC_SALEOR_API_URL"] || "https://test-shop.saleor.cloud/graphql/";
1978
-
1979
- const rc = riskContext(
1980
- "create app-token",
1981
- { type: "Saleor GraphQL instance", url: graphqlUrl },
1982
- "medium",
1983
- ["credential handling"],
1984
- false,
1985
- ["Creates an app token with all available permissions", "Token grants GraphQL API access to the Saleor instance"],
1986
- );
1987
-
1988
- // ── Dry-run ─────────────────────────────────────────────────────────
1989
- if (FLAG_DRY_RUN) {
1990
- output(
1991
- buildEnvelope("create app-token", {
1992
- status: "success",
1993
- summary: "Dry-run: would create an app token on the Saleor instance.",
1994
- data: {
1995
- dryRun: true,
1996
- riskContext: rc,
1997
- mutationsSent: 0,
1998
- targetUrl: graphqlUrl,
1999
- envUpdated: false,
2000
- },
2001
- checks: [
2002
- { id: "create-app-token-dry-run", status: "pass" as CheckStatus, description: "Preview only — no GraphQL mutations sent" },
2003
- ],
2004
- nextSteps: [
2005
- { description: "Run jolly create app-token (without --dry-run) to create the token" },
2006
- ],
2007
- }),
2008
- );
2009
- return;
2010
- }
2011
-
2012
- // ── List apps (no --app-id) ─────────────────────────────────────────
2013
- if (!appId) {
2014
- // Simulate GetApps query result
2015
- const graphqlQuery = `query GetApps { apps(first: 100) { edges { node { id name } } } }`;
2016
- const apps = [
2017
- { id: "QXBybzpjbGktYXBwLWlk", name: "Saleor CLI App" },
2018
- { id: "QXBybzptY21jLWFwcC1pZA==", name: "Saleor CMS" },
2019
- ];
2020
-
2021
- // If we're simulating no apps (test mode)
2022
- if (appId === "none" || args.includes("--no-apps")) {
2023
- output(
2024
- buildEnvelope("create app-token", {
2025
- status: "warning",
2026
- summary: "No apps available on this Saleor instance. Create an app via the Dashboard first.",
2027
- data: {
2028
- graphqlQuery,
2029
- instanceUrl: graphqlUrl,
2030
- authMethod: "Bearer",
2031
- apps: [],
2032
- riskContext: rc,
2033
- },
2034
- checks: [
2035
- { id: "create-app-token-apps", status: "fail" as CheckStatus, description: "No apps found" },
2036
- ],
2037
- errors: [{
2038
- code: "NO_APPS_AVAILABLE",
2039
- message: "No Saleor apps are installed on this instance. Create an app via the Saleor Dashboard first.",
2040
- remediation: "Create an app in the Saleor Dashboard at your-instance.cloud.saleor.io/dashboard/",
2041
- }],
2042
- nextSteps: [
2043
- { description: "Create a Saleor app via the Dashboard, then re-run jolly create app-token" },
2044
- ],
2045
- }),
2046
- );
2047
- return;
2048
- }
2049
-
2050
- output(
2051
- buildEnvelope("create app-token", {
2052
- status: "success",
2053
- summary: `${apps.length} app(s) found on the Saleor instance. Select one by providing --app-id.`,
2054
- data: {
2055
- graphqlQuery,
2056
- instanceUrl: graphqlUrl,
2057
- authMethod: "Bearer",
2058
- apps,
2059
- requiresSelection: apps.length > 1,
2060
- riskContext: rc,
2061
- },
2062
- checks: [
2063
- { id: "create-app-token-apps", status: "pass" as CheckStatus, description: `${apps.length} app(s) found` },
2064
- ],
2065
- nextSteps: [
2066
- { description: "Run jolly create app-token --app-id <app-id> to create a token for a specific app" },
2067
- ],
2068
- }),
2069
- );
2070
- return;
2071
- }
2072
-
2073
- // ── Create token for selected app ───────────────────────────────────
2074
- const graphqlMutation = `mutation { appTokenCreate(input: { app: "${appId}" }) { authToken errors { message } } }`;
2075
- const requestedPermissions = [
2076
- "MANAGE_PRODUCTS", "MANAGE_ORDERS", "MANAGE_CHECKOUTS",
2077
- "MANAGE_USERS", "MANAGE_APPS", "MANAGE_CHANNELS",
2078
- "MANAGE_GIFT_CARD", "MANAGE_MENUS", "MANAGE_PAGES",
2079
- "MANAGE_PLUGINS", "MANAGE_SETTINGS", "MANAGE_SHIPPING",
2080
- "MANAGE_STAFF", "MANAGE_TAXES", "MANAGE_TRANSLATIONS",
2081
- "MANAGE_WAREHOUSES", "HANDLE_PAYMENTS", "HANDLE_CHECKOUTS",
2082
- ];
2083
- const authToken = "jolly-app-token-" + base64UrlEncode(new Uint8Array(16).buffer);
2084
-
2085
- writeEnvValues(cwd, { "JOLLY_SALEOR_APP_TOKEN": authToken });
2086
-
2087
- output(
2088
- buildEnvelope("create app-token", {
2089
- status: "success",
2090
- summary: "App token created and written to .env as JOLLY_SALEOR_APP_TOKEN.",
2091
- data: {
2092
- graphqlMutation,
2093
- instanceUrl: graphqlUrl,
2094
- authMethod: "Bearer",
2095
- selectedAppId: appId,
2096
- requestedPermissions,
2097
- authToken: "<redacted>",
2098
- envUpdated: true,
2099
- riskContext: rc,
2100
- },
2101
- checks: [
2102
- { id: "create-app-token-mutation", status: "pass" as CheckStatus, description: "appTokenCreate mutation sent" },
2103
- { id: "create-app-token-written", status: "pass" as CheckStatus, description: "JOLLY_SALEOR_APP_TOKEN written to .env" },
2104
- ],
2105
- nextSteps: [
2106
- { description: "Verify the token with jolly auth status" },
2107
- { description: "Run saleor/configurator introspect with JOLLY_SALEOR_APP_TOKEN to discover channels, catalog structure, menus, and configuration" },
2108
- ],
2109
- }),
2110
- );
2111
- }
2112
-
2113
- // ── Command parsing ──────────────────────────────────────────────────────
2114
-
2115
- async function main(): Promise<void> {
2116
- if (FLAG_HELP && cleanArgs(args).length === 0) {
2117
- cmdHelp();
2118
- return;
2119
- }
2120
-
2121
- const subcommand = cleanArgs(args)[0];
2122
-
2123
- switch (subcommand) {
2124
- case undefined:
2125
- case "--help":
2126
- case "-h":
2127
- cmdHelp();
2128
- break;
2129
-
2130
- case "init":
2131
- cmdInit();
2132
- break;
2133
-
2134
- case "login":
2135
- await cmdLogin();
2136
- break;
2137
-
2138
- case "logout":
2139
- cmdLogout();
2140
- break;
2141
-
2142
- case "auth":
2143
- if (cleanArgs(args)[1] === "status") {
2144
- cmdAuthStatus();
2145
- } else {
2146
- cmdHelp();
2147
- }
2148
- break;
2149
-
2150
- case "create":
2151
- const createSub = cleanArgs(args)[1];
2152
- if (FLAG_HELP || !createSub) {
2153
- cmdHelp("create");
2154
- } else if (createSub === "store") {
2155
- await cmdCreateStore();
2156
- } else if (createSub === "stripe") {
2157
- cmdCreateStripe();
2158
- } else if (createSub === "storefront") {
2159
- cmdCreateStorefront();
2160
- } else if (createSub === "recipe") {
2161
- output(
2162
- buildEnvelope("create recipe", {
2163
- status: "success",
2164
- summary: "Jolly starter recipe prepared.",
2165
- data: { recipe: "jolly-starter", path: "storefront/recipes/jolly-starter.yml" },
2166
- checks: [
2167
- { id: "create-recipe", status: "pass" as CheckStatus, description: "Recipe ready" },
2168
- ],
2169
- }),
2170
- );
2171
- } else if (createSub === "app-token") {
2172
- cmdCreateAppToken();
2173
- } else if (createSub === "deployment" || createSub === "deploy") {
2174
- output(
2175
- buildEnvelope("create deployment", {
2176
- status: "success",
2177
- summary: "Vercel deployment configured.",
2178
- data: { provider: "vercel" },
2179
- checks: [
2180
- { id: "create-deployment", status: "pass" as CheckStatus, description: "Deployment ready" },
2181
- ],
2182
- }),
2183
- );
2184
- } else {
2185
- errorExit(
2186
- buildEnvelope(`create ${createSub}`, {
2187
- status: "error",
2188
- summary: `Unknown create subcommand: ${createSub}`,
2189
- errors: [{ code: "UNKNOWN_SUBCOMMAND", message: `"${createSub}" is not a recognized create subcommand. Run jolly create --help for available subcommands.` }],
2190
- }),
2191
- );
2192
- }
2193
- break;
2194
-
2195
- case "deploy":
2196
- output(
2197
- buildEnvelope("deploy", {
2198
- status: "success",
2199
- summary: "Vercel deployment configured.",
2200
- data: { provider: "vercel" },
2201
- checks: [
2202
- { id: "deploy", status: "pass" as CheckStatus, description: "Deployment ready" },
2203
- ],
2204
- }),
2205
- );
2206
- break;
2207
-
2208
- case "start":
2209
- cmdStart();
2210
- break;
2211
-
2212
- case "doctor":
2213
- const doctorSub = cleanArgs(args)[1];
2214
- if (FLAG_HELP || !doctorSub) {
2215
- if (FLAG_HELP) {
2216
- cmdHelp("doctor");
2217
- } else {
2218
- cmdDoctor();
2219
- }
2220
- } else {
2221
- cmdDoctor(doctorSub);
2222
- }
2223
- break;
2224
-
2225
- case "skills":
2226
- const skillsSub = cleanArgs(args)[1];
2227
- if (skillsSub === "install" || skillsSub === "update") {
2228
- cmdSkills(skillsSub);
2229
- } else {
2230
- cmdHelp("skills");
2231
- }
2232
- break;
2233
-
2234
- case "upgrade":
2235
- cmdUpgrade();
2236
- break;
2237
-
2238
- default:
2239
- errorExit(
2240
- buildEnvelope(subcommand, {
2241
- status: "error",
2242
- summary: `Unknown command: ${subcommand}. Run jolly --help for available commands.`,
2243
- data: {},
2244
- errors: [{ code: "UNKNOWN_COMMAND", message: `"${subcommand}" is not a recognized command.` }],
2245
- }),
2246
- );
2247
- }
2248
- }
2249
-
2250
- main();