@dk/jolly 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,2011 +0,0 @@
1
- // Jolly — the thin, skill-driven CLI (decision 2026-06-13).
2
- //
3
- // Jolly does not replace the customer's agent. It does deterministic plumbing
4
- // (login/logout/auth status, create store/app-token/stripe, init, start,
5
- // doctor, upgrade, skills) and installs the Jolly skill plus the Saleor
6
- // agent-skills; the customer's agent runs the official CLIs (`npx vercel`,
7
- // `@saleor/configurator`, `git`, `pnpm`). Jolly never shells out to the Vercel
8
- // CLI or Configurator and holds no Vercel token.
9
- //
10
- // Every command emits exactly one output envelope (feature 020):
11
- // { command, status, summary, data, checks, nextSteps, errors }
12
- // Field names are camelCase; checks[].status uses the doctor vocabulary;
13
- // errors[].code is a stable uppercase machine identifier; secrets are
14
- // referenced by name, never printed. Side-effecting actions carry a feature
15
- // 021 riskContext inside the envelope, identical for --dry-run and real runs.
16
- //
17
- // Runtime: ES module TypeScript, run directly by Bun in dev/test and by
18
- // Node >= 23 (native type stripping) in production via bin/jolly. Only Node
19
- // built-ins and the project's own src/lib/ helpers are used.
20
-
21
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
- import { join } from "node:path";
23
- import { createHash, randomBytes } from "node:crypto";
24
- import { spawnSync } from "node:child_process";
25
-
26
- import {
27
- cloudApiBase,
28
- listOrganizations,
29
- listProjects,
30
- createProject,
31
- listProjectServices,
32
- pickService,
33
- listEnvironments,
34
- createEnvironment,
35
- pollTaskStatus,
36
- getEnvironment,
37
- extractDomainUrl,
38
- acquireAppToken,
39
- CloudApiError,
40
- type CloudOrganization,
41
- } from "./lib/cloud-api.ts";
42
- import { loadEnvValues, writeEnvValues } from "./lib/env-file.ts";
43
- import { normalizeSaleorUrl } from "./lib/saleor-url.ts";
44
-
45
- // ─── Envelope types (mirror features/support/envelope.ts) ─────────────────
46
-
47
- type EnvelopeStatus = "success" | "warning" | "error";
48
- type CheckStatus = "pass" | "warning" | "fail" | "skipped" | "unknown";
49
- type RiskLevel = "low" | "medium" | "high";
50
-
51
- interface Check {
52
- id: string;
53
- status: CheckStatus;
54
- description?: string;
55
- command?: string;
56
- remediation?: string;
57
- [key: string]: unknown;
58
- }
59
-
60
- interface NextStep {
61
- description: string;
62
- command?: string;
63
- [key: string]: unknown;
64
- }
65
-
66
- interface ErrorEntry {
67
- code: string;
68
- message: string;
69
- remediation?: string;
70
- [key: string]: unknown;
71
- }
72
-
73
- interface RiskContext {
74
- action: string;
75
- target: unknown;
76
- riskLevel: RiskLevel;
77
- categories: string[];
78
- reversible: boolean;
79
- sideEffects: unknown[];
80
- dryRunAvailable: boolean;
81
- }
82
-
83
- interface Envelope {
84
- command: string;
85
- status: EnvelopeStatus;
86
- summary: string;
87
- data: Record<string, unknown>;
88
- checks: Check[];
89
- nextSteps: NextStep[];
90
- errors: ErrorEntry[];
91
- }
92
-
93
- // ─── Argv parsing ─────────────────────────────────────────────────────────
94
-
95
- interface ParsedArgs {
96
- positionals: string[];
97
- json: boolean;
98
- quiet: boolean;
99
- yes: boolean;
100
- dryRun: boolean;
101
- help: boolean;
102
- options: Record<string, string>;
103
- flags: Set<string>;
104
- }
105
-
106
- // Flags that take a value (so `--name foo` consumes `foo`).
107
- const VALUE_FLAGS = new Set([
108
- "token",
109
- "url",
110
- "name",
111
- "domain-label",
112
- "region",
113
- "organization",
114
- "mock-organizations",
115
- "publishable-key",
116
- "secret-key",
117
- ]);
118
-
119
- function parseArgs(argv: string[]): ParsedArgs {
120
- const positionals: string[] = [];
121
- const options: Record<string, string> = {};
122
- const flags = new Set<string>();
123
- let json = false;
124
- let quiet = false;
125
- let yes = false;
126
- let dryRun = false;
127
- let help = false;
128
-
129
- for (let i = 0; i < argv.length; i++) {
130
- const arg = argv[i];
131
- if (arg === "--json") json = true;
132
- else if (arg === "--quiet") quiet = true;
133
- else if (arg === "--yes" || arg === "-y") yes = true;
134
- else if (arg === "--dry-run") dryRun = true;
135
- else if (arg === "--help" || arg === "-h") help = true;
136
- else if (arg.startsWith("--")) {
137
- const body = arg.slice(2);
138
- const eq = body.indexOf("=");
139
- if (eq >= 0) {
140
- options[body.slice(0, eq)] = body.slice(eq + 1);
141
- } else if (VALUE_FLAGS.has(body) && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
142
- options[body] = argv[++i];
143
- } else {
144
- flags.add(body);
145
- }
146
- } else {
147
- positionals.push(arg);
148
- }
149
- }
150
-
151
- return { positionals, json, quiet, yes, dryRun, help, options, flags };
152
- }
153
-
154
- // ─── Envelope construction helpers ────────────────────────────────────────
155
-
156
- function envelope(
157
- partial: Partial<Envelope> & { command: string; status: EnvelopeStatus; summary: string },
158
- ): Envelope {
159
- return {
160
- command: partial.command,
161
- status: partial.status,
162
- summary: partial.summary,
163
- data: partial.data ?? {},
164
- checks: partial.checks ?? [],
165
- nextSteps: partial.nextSteps ?? [],
166
- errors: partial.errors ?? [],
167
- };
168
- }
169
-
170
- function errorEnvelope(
171
- command: string,
172
- summary: string,
173
- errors: ErrorEntry[],
174
- extra: Partial<Envelope> = {},
175
- ): Envelope {
176
- return envelope({
177
- command,
178
- status: "error",
179
- summary,
180
- errors,
181
- ...extra,
182
- });
183
- }
184
-
185
- // ─── Output rendering ─────────────────────────────────────────────────────
186
-
187
- function statusGlyph(status: EnvelopeStatus): string {
188
- if (status === "success") return "ok";
189
- if (status === "warning") return "warn";
190
- return "error";
191
- }
192
-
193
- function checkGlyph(status: CheckStatus): string {
194
- switch (status) {
195
- case "pass":
196
- return "pass";
197
- case "warning":
198
- return "warn";
199
- case "fail":
200
- return "fail";
201
- case "skipped":
202
- return "skip";
203
- default:
204
- return "?";
205
- }
206
- }
207
-
208
- /**
209
- * Render and emit one envelope, honoring --json / --quiet / default mode.
210
- * Returns the process exit code (non-zero only for error status).
211
- */
212
- function emit(env: Envelope, args: ParsedArgs): number {
213
- if (args.json) {
214
- process.stdout.write(JSON.stringify(env) + "\n");
215
- } else {
216
- const lines: string[] = [];
217
- lines.push(`jolly ${env.command}: [${statusGlyph(env.status)}] ${env.summary}`);
218
- if (!args.quiet) {
219
- for (const check of env.checks) {
220
- lines.push(
221
- ` - [${checkGlyph(check.status)}] ${check.id}${check.description ? `: ${check.description}` : ""}`,
222
- );
223
- }
224
- for (const step of env.nextSteps) {
225
- lines.push(` next: ${step.description}${step.command ? ` (\`${step.command}\`)` : ""}`);
226
- }
227
- for (const err of env.errors) {
228
- lines.push(
229
- ` error[${err.code}]: ${err.message}${err.remediation ? ` — ${err.remediation}` : ""}`,
230
- );
231
- }
232
- }
233
- // Human text first, then the machine-readable envelope on its own line.
234
- process.stdout.write(lines.join("\n") + "\n");
235
- process.stdout.write(JSON.stringify(env) + "\n");
236
- }
237
- return env.status === "error" ? 1 : 0;
238
- }
239
-
240
- // ─── Project directory ────────────────────────────────────────────────────
241
-
242
- function projectDir(): string {
243
- return process.cwd();
244
- }
245
-
246
- function envFilePath(): string {
247
- return join(projectDir(), ".env");
248
- }
249
-
250
- // ─── Shared skill set (features 007/001) ──────────────────────────────────
251
-
252
- interface SkillSpec {
253
- id: string;
254
- ref: string;
255
- description: string;
256
- }
257
-
258
- const DEFAULT_SKILLS: SkillSpec[] = [
259
- { id: "jolly", ref: "dmytri/jolly", description: "The Jolly end-to-end playbook" },
260
- { id: "saleor-storefront", ref: "saleor/saleor-storefront", description: "Saleor storefront guidance" },
261
- { id: "saleor-configurator", ref: "saleor/saleor-configurator", description: "Configuration-as-code guidance" },
262
- { id: "storefront-builder", ref: "saleor/storefront-builder", description: "Storefront build guidance" },
263
- { id: "saleor-core", ref: "saleor/saleor-core", description: "Saleor core concepts" },
264
- { id: "saleor-app", ref: "saleor/saleor-app", description: "Saleor app development guidance" },
265
- ];
266
-
267
- // Standard project-local skill location used by `npx skills add`.
268
- function skillsBaseDir(): string {
269
- return join(projectDir(), ".claude", "skills");
270
- }
271
-
272
- function skillInstalledOnDisk(skill: SkillSpec): boolean {
273
- // A skill is present when its directory exists on disk.
274
- const dir = join(skillsBaseDir(), skill.id);
275
- return existsSync(join(dir, "SKILL.md")) || existsSync(dir);
276
- }
277
-
278
- // ─── login / token verification (feature 018) ─────────────────────────────
279
-
280
- const TOKEN_PAGE = "https://cloud.saleor.io/tokens";
281
-
282
- function loginRiskContext(dryRunAvailable = true): RiskContext {
283
- return {
284
- action: "login",
285
- target: cloudApiBase(),
286
- riskLevel: "medium",
287
- categories: ["credential handling"],
288
- reversible: true,
289
- sideEffects: ["Writes JOLLY_SALEOR_CLOUD_TOKEN to .env when verification permits"],
290
- dryRunAvailable,
291
- };
292
- }
293
-
294
- async function commandLogin(args: ParsedArgs): Promise<Envelope> {
295
- const command = "login";
296
- const token = args.options["token"];
297
- const browser = args.flags.has("browser");
298
-
299
- // --browser flows (PKCE preview, or honest unavailability) -------------
300
- if (browser) {
301
- if (args.dryRun) {
302
- return loginBrowserDryRun(command);
303
- }
304
- // Real browser/Playwright callback flow is not implemented on this VM.
305
- return errorEnvelope(
306
- command,
307
- "Browser-based login is not available in this environment.",
308
- [
309
- {
310
- code: "BROWSER_LOGIN_UNAVAILABLE",
311
- message:
312
- "No native browser or Playwright callback flow is available to complete browser OAuth.",
313
- remediation: `Create a token at ${TOKEN_PAGE} and run \`jolly login --token <value>\`.`,
314
- },
315
- ],
316
- { data: { riskContext: loginRiskContext() } },
317
- );
318
- }
319
-
320
- if (!token) {
321
- return errorEnvelope(
322
- command,
323
- "No token provided and browser login is not available here.",
324
- [
325
- {
326
- code: "NO_LOGIN_METHOD",
327
- message:
328
- "jolly login needs `--token <value>` in this environment (no browser/Playwright callback flow).",
329
- remediation: `Create a token at ${TOKEN_PAGE} and run \`jolly login --token <value>\`.`,
330
- },
331
- ],
332
- {
333
- nextSteps: [
334
- {
335
- description: `Create a Saleor Cloud token at ${TOKEN_PAGE}, then run jolly login --token <value>.`,
336
- command: "jolly login --token <value>",
337
- },
338
- ],
339
- data: { riskContext: loginRiskContext() },
340
- },
341
- );
342
- }
343
-
344
- // --token --dry-run: write nothing, show riskContext + nextSteps -------
345
- if (args.dryRun) {
346
- return envelope({
347
- command,
348
- status: "success",
349
- summary: "Previewed token login; nothing was written.",
350
- data: { riskContext: loginRiskContext(), dryRun: true },
351
- nextSteps: [
352
- {
353
- description: "Run jolly login --token <value> to verify and store the token.",
354
- command: "jolly login --token <value>",
355
- },
356
- ],
357
- });
358
- }
359
-
360
- // Real --token login: verify via authenticated GET of organizations/ ----
361
- let orgs: CloudOrganization[] | undefined;
362
- let verificationFailure: unknown;
363
- try {
364
- orgs = await listOrganizations(token);
365
- } catch (err) {
366
- verificationFailure = err;
367
- }
368
-
369
- if (
370
- verificationFailure instanceof CloudApiError &&
371
- (verificationFailure.httpStatus === 401 || verificationFailure.httpStatus === 403)
372
- ) {
373
- // Invalid token: write nothing, error honestly.
374
- return errorEnvelope(
375
- command,
376
- "The token was rejected by the Cloud API. Nothing was written.",
377
- [
378
- {
379
- code: "INVALID_TOKEN",
380
- message: "Saleor Cloud rejected the token (HTTP 401/403). It was not stored.",
381
- remediation: `Create a new token at ${TOKEN_PAGE} and try again.`,
382
- },
383
- ],
384
- {
385
- checks: [
386
- {
387
- id: "cloud-token-verification",
388
- status: "fail",
389
- description: "Token rejected by the Cloud API.",
390
- },
391
- ],
392
- data: { riskContext: loginRiskContext() },
393
- nextSteps: [
394
- { description: `Create a new token at ${TOKEN_PAGE}.`, command: `open ${TOKEN_PAGE}` },
395
- ],
396
- },
397
- );
398
- }
399
-
400
- if (verificationFailure) {
401
- // Unreachable / 5xx / timeout: store token, warn "stored, not verified".
402
- writeEnvValues(projectDir(), { JOLLY_SALEOR_CLOUD_TOKEN: token });
403
- return envelope({
404
- command,
405
- status: "warning",
406
- summary: "Token stored, not verified — the Cloud API was unreachable.",
407
- data: {
408
- cloudTokenStored: true,
409
- verified: false,
410
- verification: "stored, not verified",
411
- riskContext: loginRiskContext(),
412
- },
413
- checks: [
414
- {
415
- id: "cloud-token-verification",
416
- status: "unknown",
417
- description: "stored, not verified — the Cloud API was unreachable.",
418
- },
419
- ],
420
- nextSteps: [
421
- {
422
- description: "Re-run jolly login when the Cloud API is reachable to verify the token.",
423
- command: "jolly login --token <value>",
424
- },
425
- ],
426
- });
427
- }
428
-
429
- // Verified: store token + the real organization name.
430
- const orgName = resolveOrgName(orgs ?? []);
431
- const values: Record<string, string> = { JOLLY_SALEOR_CLOUD_TOKEN: token };
432
- if (orgName) values["JOLLY_SALEOR_ORGANIZATION"] = orgName;
433
- writeEnvValues(projectDir(), values);
434
-
435
- return envelope({
436
- command,
437
- status: "success",
438
- summary: orgName
439
- ? `Token verified and stored. Authenticated as "${orgName}".`
440
- : "Token verified and stored.",
441
- data: {
442
- cloudTokenStored: true,
443
- verified: true,
444
- accountContext: orgName ?? "unknown",
445
- riskContext: loginRiskContext(),
446
- },
447
- checks: [
448
- {
449
- id: "cloud-token-verification",
450
- status: "pass",
451
- description: "Token verified against the Cloud API organizations endpoint.",
452
- },
453
- ],
454
- nextSteps: [
455
- {
456
- description: "Run jolly create store to provision a Saleor Cloud environment.",
457
- command: "jolly create store --create-environment",
458
- },
459
- ],
460
- });
461
- }
462
-
463
- function resolveOrgName(orgs: CloudOrganization[]): string | undefined {
464
- const first = orgs[0];
465
- if (!first) return undefined;
466
- const name = first.name ?? first.slug;
467
- return typeof name === "string" && name.length > 0 ? name : undefined;
468
- }
469
-
470
- function base64url(buf: Buffer): string {
471
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
472
- }
473
-
474
- function loginBrowserDryRun(command: string): Envelope {
475
- const verifier = base64url(randomBytes(32));
476
- const challenge = base64url(createHash("sha256").update(verifier).digest());
477
- const state = base64url(randomBytes(16));
478
- const redirectUri = "http://127.0.0.1:5375/callback";
479
- const authBase = "https://auth.saleor.io/realms/saleor-cloud/protocol/openid-connect/auth";
480
- const params = new URLSearchParams({
481
- response_type: "code",
482
- client_id: "saleor-cli",
483
- code_challenge: challenge,
484
- code_challenge_method: "S256",
485
- state,
486
- redirect_uri: redirectUri,
487
- scope: "email openid profile",
488
- });
489
- const authorizationUrl = `${authBase}?${params.toString()}`;
490
-
491
- // The code-exchange preview: the two real POSTs the localhost callback would
492
- // make, described without sending them or claiming any of them succeeded
493
- // (feature 018, "previews the OAuth code exchange requests"). The token
494
- // endpoint is Keycloak (auth.saleor.io); the resulting OIDC id_token is then
495
- // exchanged for a Cloud API token at /platform/api/tokens.
496
- const tokenEndpoint =
497
- "https://auth.saleor.io/realms/saleor-cloud/protocol/openid-connect/token";
498
- const tokensEndpoint = `${cloudApiBase()}/tokens`;
499
- const exchangePreview = {
500
- tokenExchange: {
501
- method: "POST",
502
- url: tokenEndpoint,
503
- body: {
504
- grant_type: "authorization_code",
505
- code: "<authorization code from the localhost callback>",
506
- code_verifier: "<the PKCE code_verifier>",
507
- client_id: "saleor-cli",
508
- redirect_uri: redirectUri,
509
- },
510
- },
511
- cloudTokenExchange: {
512
- method: "POST",
513
- url: tokensEndpoint,
514
- requestPath: "/platform/api/tokens",
515
- body: { id_token: "<the OIDC id_token returned by Keycloak>" },
516
- },
517
- };
518
-
519
- return envelope({
520
- command,
521
- status: "success",
522
- summary:
523
- "Prepared the browser OAuth authorization URL and code-exchange preview (PKCE). Nothing was written.",
524
- data: {
525
- dryRun: true,
526
- authorizationUrl,
527
- pkce: { codeChallengeMethod: "S256", codeChallenge: challenge },
528
- state,
529
- redirectUri,
530
- scope: "email openid profile",
531
- clientId: "saleor-cli",
532
- responseType: "code",
533
- exchangePreview,
534
- riskContext: loginRiskContext(),
535
- },
536
- nextSteps: [
537
- {
538
- description:
539
- "Open the authorization URL in a browser to complete OAuth, or use jolly login --token <value>.",
540
- command: "jolly login --browser",
541
- },
542
- ],
543
- });
544
- }
545
-
546
- // ─── logout (feature 018) ─────────────────────────────────────────────────
547
-
548
- const MANAGED_AUTH_VARS = [
549
- "JOLLY_SALEOR_CLOUD_TOKEN",
550
- "JOLLY_SALEOR_APP_TOKEN",
551
- "JOLLY_SALEOR_ORGANIZATION",
552
- ];
553
-
554
- function commandLogout(_args: ParsedArgs): Envelope {
555
- const command = "logout";
556
- const before = loadEnvValues(projectDir());
557
- const path = envFilePath();
558
- const removed: string[] = [];
559
-
560
- if (existsSync(path)) {
561
- const lineRe = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
562
- const kept = readFileSync(path, "utf8")
563
- .split("\n")
564
- .filter((line) => {
565
- const m = lineRe.exec(line);
566
- if (m && MANAGED_AUTH_VARS.includes(m[1])) {
567
- removed.push(m[1]);
568
- return false;
569
- }
570
- return true;
571
- });
572
- // Rewrite .env without the managed auth vars, preserving everything else
573
- // (comments, blank lines, third-party credentials) verbatim.
574
- let text = kept.join("\n").replace(/\n+$/, "");
575
- text = text.length > 0 ? text + "\n" : "";
576
- writeFileSync(path, text);
577
- }
578
-
579
- return envelope({
580
- command,
581
- status: "success",
582
- summary:
583
- removed.length > 0
584
- ? `Removed Jolly-managed Saleor auth values from .env (${[...new Set(removed)].join(", ")}).`
585
- : "No Jolly-managed Saleor auth values were present in .env.",
586
- data: {
587
- removed: [...new Set(removed)],
588
- preservedOthers: true,
589
- },
590
- checks: [
591
- {
592
- id: "auth-cleared",
593
- status: "pass",
594
- description: "Jolly-managed Saleor auth values are no longer in .env.",
595
- },
596
- ],
597
- nextSteps: [
598
- {
599
- description: "Run jolly login to authenticate again when needed.",
600
- command: "jolly login --token <value>",
601
- },
602
- ],
603
- });
604
- }
605
-
606
- // ─── auth status (feature 018) ────────────────────────────────────────────
607
-
608
- function commandAuthStatus(_args: ParsedArgs): Envelope {
609
- const command = "auth status";
610
- const values = loadEnvValues(projectDir());
611
- const hasCloudToken = Boolean(values["JOLLY_SALEOR_CLOUD_TOKEN"]);
612
- const hasAppToken = Boolean(values["JOLLY_SALEOR_APP_TOKEN"]);
613
- const org = values["JOLLY_SALEOR_ORGANIZATION"];
614
- const accountContext = org && org.length > 0 ? org : "unknown";
615
-
616
- const checks: Check[] = [
617
- {
618
- id: "cloud-token-configured",
619
- status: hasCloudToken ? "pass" : "warning",
620
- description: hasCloudToken
621
- ? "JOLLY_SALEOR_CLOUD_TOKEN is configured in .env."
622
- : "JOLLY_SALEOR_CLOUD_TOKEN is not configured.",
623
- },
624
- {
625
- id: "app-token-configured",
626
- status: hasAppToken ? "pass" : "skipped",
627
- description: hasAppToken
628
- ? "JOLLY_SALEOR_APP_TOKEN is configured in .env."
629
- : "JOLLY_SALEOR_APP_TOKEN is not configured.",
630
- },
631
- ];
632
-
633
- return envelope({
634
- command,
635
- status: "success",
636
- summary: hasCloudToken
637
- ? `Saleor Cloud authentication is configured (account context: ${accountContext}).`
638
- : "Saleor Cloud authentication is not configured.",
639
- data: {
640
- hasCloudToken,
641
- hasAppToken,
642
- accountContext,
643
- },
644
- checks,
645
- nextSteps: hasCloudToken
646
- ? []
647
- : [
648
- {
649
- description: "Run jolly login to configure Saleor Cloud authentication.",
650
- command: "jolly login --token <value>",
651
- },
652
- ],
653
- });
654
- }
655
-
656
- // ─── create store (features 012/024) ──────────────────────────────────────
657
-
658
- function createStoreRiskContext(target: unknown, dryRunAvailable = true): RiskContext {
659
- return {
660
- action: "create store",
661
- target,
662
- riskLevel: "medium",
663
- categories: ["billing", "production configuration changes"],
664
- reversible: false,
665
- sideEffects: [
666
- "Creates a Saleor Cloud project and/or environment",
667
- "Writes NEXT_PUBLIC_SALEOR_API_URL and JOLLY_SALEOR_APP_TOKEN to .env",
668
- ],
669
- dryRunAvailable,
670
- };
671
- }
672
-
673
- async function commandCreateStore(args: ParsedArgs): Promise<Envelope> {
674
- const command = "create store";
675
- const url = args.options["url"];
676
-
677
- // Mode 1: write a pasted Saleor URL to .env (feature 012). -------------
678
- if (url && !args.flags.has("create-environment")) {
679
- const normalized = normalizeSaleorUrl(url);
680
- if (!normalized.endpoint) {
681
- return errorEnvelope(
682
- command,
683
- "The provided URL could not be normalized to a Saleor GraphQL endpoint.",
684
- [
685
- {
686
- code: "INVALID_SALEOR_URL",
687
- message: normalized.clarification ?? "Unrecognized Saleor URL.",
688
- remediation: "Paste a Saleor Dashboard, GraphQL, or root Saleor Cloud URL.",
689
- },
690
- ],
691
- { data: { riskContext: createStoreRiskContext(url) } },
692
- );
693
- }
694
-
695
- if (args.dryRun) {
696
- return envelope({
697
- command,
698
- status: "success",
699
- summary: "Previewed storing the Saleor endpoint; nothing was written.",
700
- data: {
701
- dryRun: true,
702
- normalizedUrl: normalized.endpoint,
703
- riskContext: createStoreRiskContext(normalized.endpoint),
704
- },
705
- nextSteps: [
706
- {
707
- description: "Run the command without --dry-run to write the endpoint to .env.",
708
- command: `jolly create store --url ${normalized.endpoint}`,
709
- },
710
- ],
711
- });
712
- }
713
-
714
- // Collision guard (feature 022): if .env already carries a DIFFERENT
715
- // endpoint Jolly is being asked to overwrite, pause and ask rather than
716
- // silently replacing state Jolly did not create. The agent decides via
717
- // the feature 021 riskContext; --yes is its explicit go-ahead.
718
- const existingEndpoint = loadEnvValues(projectDir())["NEXT_PUBLIC_SALEOR_API_URL"];
719
- if (
720
- existingEndpoint &&
721
- existingEndpoint !== normalized.endpoint &&
722
- !args.flags.has("yes")
723
- ) {
724
- return envelope({
725
- command,
726
- status: "warning",
727
- summary:
728
- "A different NEXT_PUBLIC_SALEOR_API_URL already exists in .env; " +
729
- "Jolly paused instead of overwriting it. Re-run with --yes to replace it.",
730
- data: {
731
- collision: true,
732
- existingEndpoint,
733
- requestedEndpoint: normalized.endpoint,
734
- riskContext: {
735
- action: "overwrite Saleor endpoint",
736
- target: "NEXT_PUBLIC_SALEOR_API_URL in .env",
737
- riskLevel: "medium",
738
- categories: ["destructive operations", "production configuration changes"],
739
- reversible: false,
740
- sideEffects: [
741
- `Replaces the existing endpoint "${existingEndpoint}" with "${normalized.endpoint}"`,
742
- ],
743
- dryRunAvailable: true,
744
- },
745
- },
746
- checks: [
747
- {
748
- id: "saleor-endpoint-collision",
749
- status: "warning",
750
- description:
751
- "An existing NEXT_PUBLIC_SALEOR_API_URL would be overwritten; not replaced without --yes.",
752
- },
753
- ],
754
- nextSteps: [
755
- {
756
- description:
757
- "Re-run with --yes to overwrite the existing endpoint (the agent decides).",
758
- command: `jolly create store --url ${normalized.endpoint} --yes`,
759
- },
760
- ],
761
- });
762
- }
763
-
764
- writeEnvValues(projectDir(), { NEXT_PUBLIC_SALEOR_API_URL: normalized.endpoint });
765
- return envelope({
766
- command,
767
- status: "success",
768
- summary: "Stored the Saleor GraphQL endpoint as NEXT_PUBLIC_SALEOR_API_URL.",
769
- data: {
770
- stored: true,
771
- envVar: "NEXT_PUBLIC_SALEOR_API_URL",
772
- riskContext: createStoreRiskContext(normalized.endpoint),
773
- },
774
- checks: [
775
- {
776
- id: "saleor-endpoint-stored",
777
- status: "pass",
778
- description: "NEXT_PUBLIC_SALEOR_API_URL written to .env.",
779
- },
780
- ],
781
- nextSteps: [
782
- {
783
- description: "Run jolly create app-token to acquire a Saleor app token.",
784
- command: "jolly create app-token",
785
- },
786
- ],
787
- });
788
- }
789
-
790
- // Mode 2: provision a Saleor Cloud environment via the Cloud API. ------
791
- const token = process.env["JOLLY_SALEOR_CLOUD_TOKEN"];
792
- const region = args.options["region"] ?? "us-east-1";
793
- const orgOverride = args.options["organization"];
794
- const name = args.options["name"];
795
- const domainLabel = args.options["domain-label"];
796
-
797
- if (!token) {
798
- return errorEnvelope(
799
- command,
800
- "No Saleor Cloud token is configured; cannot provision a store.",
801
- [
802
- {
803
- code: "MISSING_CLOUD_TOKEN",
804
- message: "JOLLY_SALEOR_CLOUD_TOKEN is required to create a Saleor Cloud store.",
805
- remediation: "Run `jolly login --token <value>` first.",
806
- },
807
- ],
808
- {
809
- data: {
810
- riskContext: createStoreRiskContext(`${cloudApiBase()} (organization unresolved)`),
811
- },
812
- nextSteps: [
813
- {
814
- description: "Run jolly login to acquire a Saleor Cloud token.",
815
- command: "jolly login --token <value>",
816
- },
817
- ],
818
- },
819
- );
820
- }
821
-
822
- // Resolve the organization. --mock-organizations injects a deterministic
823
- // org list for the @logic multi-org warning scenario (no network).
824
- let orgs: CloudOrganization[];
825
- const mock = args.flags.has("mock-organizations")
826
- ? ""
827
- : (args.options["mock-organizations"] ?? undefined);
828
- if (mock !== undefined) {
829
- orgs = (mock.length > 0 ? mock.split(",") : ["org-one", "org-two"]).map((slug) => ({
830
- slug: slug.trim(),
831
- }));
832
- } else {
833
- try {
834
- orgs = await listOrganizations(token);
835
- } catch (err) {
836
- return cloudErrorEnvelope(command, err, createStoreRiskContext(cloudApiBase()));
837
- }
838
- }
839
-
840
- let selectedOrg: string;
841
- let multiOrgWarning = false;
842
- if (orgOverride) {
843
- selectedOrg = orgOverride;
844
- } else if (orgs.length === 0) {
845
- return errorEnvelope(
846
- command,
847
- "The Cloud token has access to no organizations.",
848
- [
849
- {
850
- code: "NO_ORGANIZATIONS",
851
- message: "No organizations are accessible with this Cloud token.",
852
- remediation: "Confirm the token's permissions at https://cloud.saleor.io/tokens.",
853
- },
854
- ],
855
- { data: { riskContext: createStoreRiskContext(cloudApiBase()) } },
856
- );
857
- } else if (orgs.length === 1) {
858
- selectedOrg = orgs[0].slug;
859
- } else {
860
- selectedOrg = orgs[0].slug;
861
- multiOrgWarning = true;
862
- }
863
-
864
- const resolvedTarget = `${cloudApiBase()}/organizations/${selectedOrg}/environments/`;
865
- const effectiveName = name ?? "jolly-store";
866
- const effectiveDomainLabel = domainLabel ?? effectiveName;
867
-
868
- // --dry-run: show the real resolved request, write nothing. -----------
869
- if (args.dryRun) {
870
- const requestBody = {
871
- name: effectiveName,
872
- project: effectiveName,
873
- domain_label: effectiveDomainLabel,
874
- database_population: "sample",
875
- service: "saleor",
876
- region,
877
- };
878
- const env = envelope({
879
- command,
880
- status: multiOrgWarning ? "warning" : "success",
881
- summary: multiOrgWarning
882
- ? `Previewed environment creation in "${selectedOrg}" (token has multiple organizations).`
883
- : `Previewed environment creation in organization "${selectedOrg}".`,
884
- data: {
885
- dryRun: true,
886
- method: "POST",
887
- requestPath: `/platform/api/organizations/${selectedOrg}/environments/`,
888
- requestUrl: resolvedTarget,
889
- organization: selectedOrg,
890
- region,
891
- databaseTemplate: "sample",
892
- requestBody,
893
- riskContext: createStoreRiskContext(resolvedTarget),
894
- },
895
- nextSteps: [
896
- {
897
- description: "Run the command without --dry-run to create the environment.",
898
- command: "jolly create store --create-environment",
899
- },
900
- ],
901
- });
902
- if (multiOrgWarning) {
903
- env.data["availableOrganizations"] = orgs.map((o) => o.slug);
904
- env.data["selectedOrganization"] = selectedOrg;
905
- }
906
- return env;
907
- }
908
-
909
- // Multi-org without --organization (non-dry-run): warn before proceeding
910
- // so the agent can re-run with the right org (feature 012).
911
- if (multiOrgWarning) {
912
- return envelope({
913
- command,
914
- status: "warning",
915
- summary: `The Cloud token has multiple organizations; Jolly selected "${selectedOrg}".`,
916
- data: {
917
- availableOrganizations: orgs.map((o) => o.slug),
918
- selectedOrganization: selectedOrg,
919
- riskContext: createStoreRiskContext(resolvedTarget),
920
- },
921
- checks: [
922
- {
923
- id: "organization-selection",
924
- status: "warning",
925
- description: `Selected "${selectedOrg}". Re-run with --organization <slug> if this is wrong.`,
926
- },
927
- ],
928
- nextSteps: [
929
- {
930
- description: `Re-run with --organization <slug> to choose explicitly. Available: ${orgs
931
- .map((o) => o.slug)
932
- .join(", ")}.`,
933
- command: `jolly create store --create-environment --organization ${selectedOrg}`,
934
- },
935
- ],
936
- });
937
- }
938
-
939
- // Real provisioning: create-or-reuse project, create env, poll, write .env
940
- try {
941
- const projects = await listProjects(token, selectedOrg);
942
- const existingProject = projects.find((p) => p.name === effectiveName) ?? projects[0];
943
- let project: { name: string; slug?: string };
944
- let projectCreated: boolean;
945
- if (existingProject) {
946
- project = existingProject;
947
- projectCreated = false;
948
- } else {
949
- project = await createProject(token, selectedOrg, {
950
- name: effectiveName,
951
- plan: "dev",
952
- region,
953
- });
954
- projectCreated = true;
955
- }
956
- const projectSlug = project.slug ?? project.name;
957
-
958
- // Reuse an environment with our domain label if it already exists
959
- // (idempotency, feature 022).
960
- const existingEnvs = await listEnvironments(token, selectedOrg);
961
- const existingEnv = existingEnvs.find(
962
- (e) => e.domain_label === effectiveDomainLabel || e.name === effectiveName,
963
- );
964
-
965
- let domainUrl: string;
966
- let environmentCreated: boolean;
967
- let environment: { key?: unknown; name?: unknown };
968
- if (existingEnv) {
969
- domainUrl = extractDomainUrl(undefined, existingEnv, effectiveDomainLabel);
970
- environmentCreated = false;
971
- environment = existingEnv;
972
- } else {
973
- const services = await listProjectServices(token, selectedOrg, projectSlug);
974
- const service = pickService(services, region);
975
- const created = await createEnvironment(token, selectedOrg, {
976
- name: effectiveName,
977
- project: projectSlug,
978
- domain_label: effectiveDomainLabel,
979
- database_population: "sample",
980
- service,
981
- region,
982
- });
983
- const taskId = created.task_id;
984
- let task = undefined;
985
- if (taskId) task = await pollTaskStatus(String(taskId));
986
- const refreshed = created.key
987
- ? await getEnvironment(token, selectedOrg, String(created.key))
988
- : created;
989
- domainUrl = extractDomainUrl(task, refreshed, effectiveDomainLabel);
990
- environmentCreated = true;
991
- environment = refreshed ?? created;
992
- }
993
- const environmentKey =
994
- typeof environment.key === "string" ? environment.key : undefined;
995
- const environmentName =
996
- typeof environment.name === "string" ? environment.name : effectiveName;
997
-
998
- const values: Record<string, string> = { NEXT_PUBLIC_SALEOR_API_URL: domainUrl };
999
-
1000
- // Acquire an app token against the new instance GraphQL endpoint.
1001
- let appTokenStored = false;
1002
- try {
1003
- const appToken = await acquireAppToken(domainUrl, token, "Jolly Setup");
1004
- values["JOLLY_SALEOR_APP_TOKEN"] = appToken;
1005
- appTokenStored = true;
1006
- } catch {
1007
- // Non-fatal: the env exists; the agent can run create app-token later.
1008
- }
1009
-
1010
- writeEnvValues(projectDir(), values);
1011
-
1012
- return envelope({
1013
- command,
1014
- status: "success",
1015
- summary: `Saleor Cloud environment ready in "${selectedOrg}".`,
1016
- data: {
1017
- organization: selectedOrg,
1018
- organizationSlug: selectedOrg,
1019
- environmentName,
1020
- ...(environmentKey ? { environmentKey } : {}),
1021
- projectCreated,
1022
- projectReused: !projectCreated,
1023
- environmentCreated,
1024
- graphqlEndpointStored: true,
1025
- appTokenStored,
1026
- riskContext: createStoreRiskContext(resolvedTarget),
1027
- },
1028
- checks: [
1029
- {
1030
- id: "environment-provisioned",
1031
- status: "pass",
1032
- description: environmentCreated
1033
- ? "Environment created and verified via task status."
1034
- : "Existing environment reused.",
1035
- },
1036
- {
1037
- id: "app-token-acquired",
1038
- status: appTokenStored ? "pass" : "unknown",
1039
- description: appTokenStored
1040
- ? "App token acquired and stored."
1041
- : "App token not acquired; run jolly create app-token.",
1042
- },
1043
- ],
1044
- nextSteps: appTokenStored
1045
- ? []
1046
- : [
1047
- {
1048
- description: "Run jolly create app-token to acquire an app token.",
1049
- command: "jolly create app-token",
1050
- },
1051
- ],
1052
- });
1053
- } catch (err) {
1054
- return cloudErrorEnvelope(command, err, createStoreRiskContext(resolvedTarget));
1055
- }
1056
- }
1057
-
1058
- function cloudErrorEnvelope(command: string, err: unknown, riskContext: RiskContext): Envelope {
1059
- const code = err instanceof CloudApiError ? err.code : "CLOUD_API_ERROR";
1060
- const message = err instanceof Error ? err.message : String(err);
1061
- return errorEnvelope(
1062
- command,
1063
- "The Cloud API request failed. Nothing was created.",
1064
- [
1065
- {
1066
- code,
1067
- message,
1068
- remediation:
1069
- code === "ENVIRONMENT_LIMIT_REACHED"
1070
- ? "Delete an unused environment or upgrade the plan, then re-run."
1071
- : code === "DOMAIN_LABEL_TAKEN"
1072
- ? "Choose a different domain label with --domain-label <label>."
1073
- : "Confirm the Cloud token and that the Cloud API is reachable.",
1074
- },
1075
- ],
1076
- { data: { riskContext } },
1077
- );
1078
- }
1079
-
1080
- // ─── create app-token (feature 024) ───────────────────────────────────────
1081
-
1082
- function appTokenRiskContext(target: unknown): RiskContext {
1083
- return {
1084
- action: "create app-token",
1085
- target,
1086
- riskLevel: "medium",
1087
- categories: ["credential handling"],
1088
- reversible: true,
1089
- sideEffects: [
1090
- "Creates a Saleor app token via GraphQL",
1091
- "Writes JOLLY_SALEOR_APP_TOKEN to .env",
1092
- ],
1093
- dryRunAvailable: true,
1094
- };
1095
- }
1096
-
1097
- async function commandCreateAppToken(args: ParsedArgs): Promise<Envelope> {
1098
- const command = "create app-token";
1099
- const token = process.env["JOLLY_SALEOR_CLOUD_TOKEN"];
1100
- const values = loadEnvValues(projectDir());
1101
- const instanceUrl =
1102
- args.options["url"] ??
1103
- values["NEXT_PUBLIC_SALEOR_API_URL"] ??
1104
- process.env["NEXT_PUBLIC_SALEOR_API_URL"];
1105
-
1106
- if (args.dryRun) {
1107
- return envelope({
1108
- command,
1109
- status: "success",
1110
- summary: "Previewed app token creation; no GraphQL mutation was sent.",
1111
- data: {
1112
- dryRun: true,
1113
- instanceUrl: instanceUrl ?? null,
1114
- riskContext: appTokenRiskContext(instanceUrl ?? "unresolved Saleor GraphQL endpoint"),
1115
- },
1116
- nextSteps: [
1117
- {
1118
- description: "Run the command without --dry-run to create and store the app token.",
1119
- command: "jolly create app-token",
1120
- },
1121
- ],
1122
- });
1123
- }
1124
-
1125
- if (!token) {
1126
- return errorEnvelope(
1127
- command,
1128
- "No Saleor Cloud token is configured; cannot acquire an app token.",
1129
- [
1130
- {
1131
- code: "MISSING_CLOUD_TOKEN",
1132
- message: "JOLLY_SALEOR_CLOUD_TOKEN is required to acquire an app token.",
1133
- remediation: "Run `jolly login --token <value>` first.",
1134
- },
1135
- ],
1136
- { data: { riskContext: appTokenRiskContext(instanceUrl ?? "unresolved") } },
1137
- );
1138
- }
1139
-
1140
- if (!instanceUrl) {
1141
- return errorEnvelope(
1142
- command,
1143
- "No Saleor GraphQL instance URL is available.",
1144
- [
1145
- {
1146
- code: "MISSING_INSTANCE_URL",
1147
- message: "A Saleor GraphQL endpoint (NEXT_PUBLIC_SALEOR_API_URL) is required.",
1148
- remediation: "Run `jolly create store` first, or pass --url <graphql-endpoint>.",
1149
- },
1150
- ],
1151
- { data: { riskContext: appTokenRiskContext("unresolved") } },
1152
- );
1153
- }
1154
-
1155
- try {
1156
- const appToken = await acquireAppToken(instanceUrl, token, "Jolly Setup");
1157
- writeEnvValues(projectDir(), { JOLLY_SALEOR_APP_TOKEN: appToken });
1158
- return envelope({
1159
- command,
1160
- status: "success",
1161
- summary: "App token acquired and stored as JOLLY_SALEOR_APP_TOKEN.",
1162
- data: {
1163
- appTokenStored: true,
1164
- instanceUrl,
1165
- riskContext: appTokenRiskContext(instanceUrl),
1166
- },
1167
- checks: [
1168
- {
1169
- id: "app-token-acquired",
1170
- status: "pass",
1171
- description: "App token created via GraphQL and stored.",
1172
- },
1173
- ],
1174
- });
1175
- } catch (err) {
1176
- const code = err instanceof CloudApiError ? err.code : "APP_TOKEN_ACQUISITION_FAILED";
1177
- return errorEnvelope(
1178
- command,
1179
- "Could not acquire an app token. Nothing was stored.",
1180
- [
1181
- {
1182
- code,
1183
- message: err instanceof Error ? err.message : String(err),
1184
- remediation:
1185
- "Confirm the instance is reachable and the Cloud token has access; or create an app in the Saleor Dashboard.",
1186
- },
1187
- ],
1188
- { data: { riskContext: appTokenRiskContext(instanceUrl) } },
1189
- );
1190
- }
1191
- }
1192
-
1193
- // ─── create stripe (feature 005) ──────────────────────────────────────────
1194
-
1195
- function stripeRiskContext(): RiskContext {
1196
- return {
1197
- action: "create stripe",
1198
- target: ".env (JOLLY_STRIPE_PUBLISHABLE_KEY, JOLLY_STRIPE_SECRET_KEY)",
1199
- riskLevel: "medium",
1200
- categories: ["payment setup", "credential handling"],
1201
- reversible: true,
1202
- sideEffects: ["Writes Stripe test-mode keys to .env"],
1203
- dryRunAvailable: true,
1204
- };
1205
- }
1206
-
1207
- function commandCreateStripe(args: ParsedArgs): Envelope {
1208
- const command = "create stripe";
1209
- const publishable = args.options["publishable-key"];
1210
- const secret = args.options["secret-key"];
1211
-
1212
- if (!publishable || !secret) {
1213
- return errorEnvelope(
1214
- command,
1215
- "Both --publishable-key and --secret-key are required.",
1216
- [
1217
- {
1218
- code: "MISSING_STRIPE_KEYS",
1219
- message: "create stripe needs --publishable-key <pk_test_...> and --secret-key <sk_test_...>.",
1220
- remediation: "Copy both test-mode keys from the Stripe Dashboard and pass them as flags.",
1221
- },
1222
- ],
1223
- { data: { riskContext: stripeRiskContext() } },
1224
- );
1225
- }
1226
-
1227
- if (args.dryRun) {
1228
- return envelope({
1229
- command,
1230
- status: "success",
1231
- summary: "Previewed Stripe key storage; nothing was written.",
1232
- data: { dryRun: true, riskContext: stripeRiskContext() },
1233
- nextSteps: [
1234
- {
1235
- description: "Run the command without --dry-run to write the Stripe keys to .env.",
1236
- command: "jolly create stripe --publishable-key <pk> --secret-key <sk>",
1237
- },
1238
- ],
1239
- });
1240
- }
1241
-
1242
- writeEnvValues(projectDir(), {
1243
- JOLLY_STRIPE_PUBLISHABLE_KEY: publishable,
1244
- JOLLY_STRIPE_SECRET_KEY: secret,
1245
- });
1246
-
1247
- return envelope({
1248
- command,
1249
- status: "success",
1250
- summary:
1251
- "Stored Stripe test-mode keys as JOLLY_STRIPE_PUBLISHABLE_KEY and JOLLY_STRIPE_SECRET_KEY.",
1252
- data: { stored: true, riskContext: stripeRiskContext() },
1253
- checks: [
1254
- { id: "stripe-keys-stored", status: "pass", description: "Stripe test-mode keys written to .env." },
1255
- ],
1256
- nextSteps: [
1257
- {
1258
- description:
1259
- "Configure Saleor's Stripe integration via @saleor/configurator, guided by the Jolly skill.",
1260
- command: "jolly doctor stripe",
1261
- },
1262
- ],
1263
- });
1264
- }
1265
-
1266
- // ─── create dispatcher + help ─────────────────────────────────────────────
1267
-
1268
- const CREATE_SUBCOMMANDS = ["store", "app-token", "stripe"] as const;
1269
-
1270
- function commandCreateHelp(): Envelope {
1271
- const command = "create --help";
1272
- return envelope({
1273
- command,
1274
- status: "success",
1275
- summary: "jolly create exposes the plumbing subcommands store, app-token, and stripe.",
1276
- data: {
1277
- subcommands: [
1278
- {
1279
- name: "store",
1280
- description: "Provision a Saleor Cloud store/environment, or store a pasted Saleor URL.",
1281
- },
1282
- {
1283
- name: "app-token",
1284
- description: "Acquire a Saleor app token via GraphQL and write it to .env.",
1285
- },
1286
- { name: "stripe", description: "Write Stripe test-mode keys to .env." },
1287
- ],
1288
- note: "Other setup work is run by your agent via the official CLIs, guided by the Jolly skill.",
1289
- },
1290
- nextSteps: [
1291
- {
1292
- description: "Run jolly create store --create-environment to provision a Saleor Cloud environment.",
1293
- command: "jolly create store --create-environment",
1294
- },
1295
- ],
1296
- });
1297
- }
1298
-
1299
- async function commandCreate(args: ParsedArgs): Promise<Envelope> {
1300
- const sub = args.positionals[1];
1301
- if (!sub || args.help || sub === "help") {
1302
- return commandCreateHelp();
1303
- }
1304
- switch (sub) {
1305
- case "store":
1306
- return commandCreateStore(args);
1307
- case "app-token":
1308
- return commandCreateAppToken(args);
1309
- case "stripe":
1310
- return commandCreateStripe(args);
1311
- default:
1312
- return errorEnvelope("create", `Unknown create subcommand "${sub}".`, [
1313
- {
1314
- code: "UNKNOWN_CREATE_SUBCOMMAND",
1315
- message: `"${sub}" is not a create subcommand. Valid: ${CREATE_SUBCOMMANDS.join(", ")}.`,
1316
- remediation: "Run `jolly create --help` to list available subcommands.",
1317
- },
1318
- ]);
1319
- }
1320
- }
1321
-
1322
- // ─── init (feature 007) ───────────────────────────────────────────────────
1323
-
1324
- function installSkill(skill: SkillSpec): { installed: boolean; stderr?: string } {
1325
- // npx skills add <ref> — best effort; verification is on-disk below.
1326
- const result = spawnSync("npx", ["--yes", "skills", "add", skill.ref], {
1327
- cwd: projectDir(),
1328
- encoding: "utf8",
1329
- timeout: 60_000,
1330
- });
1331
- return { installed: result.status === 0, stderr: result.stderr ?? undefined };
1332
- }
1333
-
1334
- function mergeMcpJson(): { merged: boolean; warning?: string } {
1335
- const path = join(projectDir(), ".mcp.json");
1336
- const endpoint =
1337
- loadEnvValues(projectDir())["NEXT_PUBLIC_SALEOR_API_URL"] ??
1338
- process.env["NEXT_PUBLIC_SALEOR_API_URL"] ??
1339
- "https://your-store.saleor.cloud/graphql/";
1340
- const jollyEntry = {
1341
- command: "npx",
1342
- args: ["-y", "mcp-graphql"],
1343
- env: { ENDPOINT: endpoint },
1344
- };
1345
-
1346
- let config: Record<string, unknown> = { mcpServers: {} };
1347
- if (existsSync(path)) {
1348
- try {
1349
- config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
1350
- } catch {
1351
- // Leave an unparseable file untouched and warn.
1352
- return { merged: false, warning: "Existing .mcp.json is not valid JSON; left untouched." };
1353
- }
1354
- }
1355
- const servers = (
1356
- config["mcpServers"] && typeof config["mcpServers"] === "object"
1357
- ? (config["mcpServers"] as Record<string, unknown>)
1358
- : {}
1359
- ) as Record<string, unknown>;
1360
- // Merge: add our entry without removing user-authored servers.
1361
- servers["saleor-graphql"] = jollyEntry;
1362
- config["mcpServers"] = servers;
1363
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
1364
- return { merged: true };
1365
- }
1366
-
1367
- function mergeAgentsMd(): void {
1368
- const path = join(projectDir(), "AGENTS.md");
1369
- const begin = "<!-- jolly:begin -->";
1370
- const end = "<!-- jolly:end -->";
1371
- const section = `${begin}
1372
- ## Jolly
1373
-
1374
- This project uses Jolly to set up a Saleor storefront. Run \`jolly start\` to
1375
- bootstrap, then follow the Jolly skill to drive the official CLIs.
1376
- ${end}`;
1377
-
1378
- let existing = existsSync(path) ? readFileSync(path, "utf8") : "";
1379
- if (existing.includes(begin) && existing.includes(end)) {
1380
- existing = existing.replace(new RegExp(`${begin}[\\s\\S]*?${end}`), section);
1381
- } else {
1382
- existing =
1383
- existing.length > 0
1384
- ? `${existing.replace(/\n+$/, "")}\n\n${section}\n`
1385
- : `${section}\n`;
1386
- }
1387
- writeFileSync(path, existing);
1388
- }
1389
-
1390
- function commandInit(_args: ParsedArgs): Envelope {
1391
- const command = "init";
1392
- const checks: Check[] = [];
1393
- const installFailures: string[] = [];
1394
-
1395
- for (const skill of DEFAULT_SKILLS) {
1396
- const already = skillInstalledOnDisk(skill);
1397
- if (!already) {
1398
- installSkill(skill);
1399
- }
1400
- // Verify on disk — never unconditionally claim success.
1401
- const present = skillInstalledOnDisk(skill);
1402
- checks.push({
1403
- id: `skill-${skill.id}`,
1404
- status: present ? "pass" : "fail",
1405
- description: present
1406
- ? `${skill.id} present on disk${already ? " (already installed)" : ""}.`
1407
- : `${skill.id} could not be verified on disk after npx skills add.`,
1408
- });
1409
- if (!present) installFailures.push(skill.id);
1410
- }
1411
-
1412
- // Merge .mcp.json (local mcp-graphql against the customer endpoint).
1413
- const mcp = mergeMcpJson();
1414
- checks.push({
1415
- id: "mcp-config",
1416
- status: mcp.merged ? "pass" : "warning",
1417
- description: mcp.merged
1418
- ? "Merged saleor-graphql entry into .mcp.json."
1419
- : mcp.warning ?? "Could not merge .mcp.json.",
1420
- });
1421
-
1422
- // Merge AGENTS.md guidance.
1423
- mergeAgentsMd();
1424
- checks.push({
1425
- id: "agents-md",
1426
- status: "pass",
1427
- description: "Merged the Jolly section into AGENTS.md.",
1428
- });
1429
-
1430
- if (installFailures.length > 0) {
1431
- return errorEnvelope(
1432
- command,
1433
- `Some skills could not be verified on disk: ${installFailures.join(", ")}.`,
1434
- [
1435
- {
1436
- code: "SKILL_INSTALL_FAILED",
1437
- message: `Failed to install or verify: ${installFailures.join(", ")}.`,
1438
- remediation:
1439
- "Ensure `npx skills` is available and the network is reachable, then re-run `jolly init`.",
1440
- },
1441
- ],
1442
- { checks },
1443
- );
1444
- }
1445
-
1446
- return envelope({
1447
- command,
1448
- status: "success",
1449
- summary: `Installed and verified ${DEFAULT_SKILLS.length} skills; merged .mcp.json and AGENTS.md.`,
1450
- data: {
1451
- skills: DEFAULT_SKILLS.map((s) => s.id),
1452
- mcpMerged: mcp.merged,
1453
- agentsMdMerged: true,
1454
- },
1455
- checks,
1456
- nextSteps: [
1457
- {
1458
- description: "Run jolly start to bootstrap setup and get the ordered playbook.",
1459
- command: "jolly start",
1460
- },
1461
- ],
1462
- });
1463
- }
1464
-
1465
- // ─── doctor (feature 014) ─────────────────────────────────────────────────
1466
-
1467
- const DOCTOR_GROUPS = ["skills", "saleor", "storefront", "deployment", "stripe"] as const;
1468
-
1469
- function commandDoctor(args: ParsedArgs): Envelope {
1470
- const group = args.positionals[1];
1471
- const values = loadEnvValues(projectDir());
1472
- const checks: Check[] = [];
1473
-
1474
- if (
1475
- group &&
1476
- !DOCTOR_GROUPS.includes(group as (typeof DOCTOR_GROUPS)[number])
1477
- ) {
1478
- return errorEnvelope("doctor", `Unknown doctor group "${group}".`, [
1479
- {
1480
- code: "UNKNOWN_DOCTOR_GROUP",
1481
- message: `"${group}" is not a doctor group. Valid: ${DOCTOR_GROUPS.join(", ")}.`,
1482
- remediation: "Run `jolly doctor` for all checks or name a valid group.",
1483
- },
1484
- ]);
1485
- }
1486
-
1487
- const wants = (g: string) => !group || group === g;
1488
-
1489
- // CLI availability (always reportable, read-only).
1490
- if (!group) {
1491
- checks.push({
1492
- id: "cli-available",
1493
- status: "pass",
1494
- description: `Jolly CLI is available (Node ${process.versions.node}).`,
1495
- });
1496
- }
1497
-
1498
- if (wants("skills")) {
1499
- for (const skill of DEFAULT_SKILLS) {
1500
- const present = skillInstalledOnDisk(skill);
1501
- checks.push({
1502
- id: `skill-${skill.id}`,
1503
- status: present ? "pass" : "fail",
1504
- description: present ? `${skill.id} present.` : `${skill.id} not installed.`,
1505
- command: present ? undefined : "jolly init",
1506
- });
1507
- }
1508
- }
1509
-
1510
- if (wants("saleor")) {
1511
- const hasCloud = Boolean(
1512
- values["JOLLY_SALEOR_CLOUD_TOKEN"] ?? process.env["JOLLY_SALEOR_CLOUD_TOKEN"],
1513
- );
1514
- const hasEndpoint = Boolean(
1515
- values["NEXT_PUBLIC_SALEOR_API_URL"] ?? process.env["NEXT_PUBLIC_SALEOR_API_URL"],
1516
- );
1517
- const hasApp = Boolean(
1518
- values["JOLLY_SALEOR_APP_TOKEN"] ?? process.env["JOLLY_SALEOR_APP_TOKEN"],
1519
- );
1520
- checks.push({
1521
- id: "saleor-cloud-token",
1522
- status: hasCloud ? "pass" : "fail",
1523
- description: hasCloud ? "JOLLY_SALEOR_CLOUD_TOKEN present." : "No Saleor Cloud token configured.",
1524
- command: hasCloud ? undefined : "jolly login --token <value>",
1525
- });
1526
- checks.push({
1527
- id: "saleor-endpoint",
1528
- // Presence is detectable; live connectivity is a @sandbox concern, so
1529
- // report "unknown" (not a fabricated pass) when present without probing.
1530
- status: hasEndpoint ? "unknown" : "fail",
1531
- description: hasEndpoint
1532
- ? "NEXT_PUBLIC_SALEOR_API_URL is set; live connectivity not verified in this run."
1533
- : "No Saleor GraphQL endpoint configured.",
1534
- command: hasEndpoint ? undefined : "jolly create store --url <graphql-endpoint>",
1535
- });
1536
- checks.push({
1537
- id: "saleor-app-token",
1538
- status: hasApp ? "pass" : "fail",
1539
- description: hasApp ? "JOLLY_SALEOR_APP_TOKEN present." : "No Saleor app token configured.",
1540
- command: hasApp ? undefined : "jolly create app-token",
1541
- });
1542
- }
1543
-
1544
- if (wants("storefront")) {
1545
- const storefrontPresent =
1546
- existsSync(join(projectDir(), "package.json")) &&
1547
- existsSync(join(projectDir(), "src", "app"));
1548
- // Without a verified Paper storefront, report fail/unknown — never pass.
1549
- checks.push({
1550
- id: "storefront-present",
1551
- status: storefrontPresent ? "unknown" : "fail",
1552
- description: storefrontPresent
1553
- ? "A project structure exists; Paper storefront readiness not verified in this run."
1554
- : "No Paper storefront detected locally.",
1555
- command: storefrontPresent ? undefined : "Clone saleor/storefront (Paper) per the Jolly skill.",
1556
- });
1557
- }
1558
-
1559
- if (wants("deployment")) {
1560
- // Deployment is agent-run via the Vercel CLI; Jolly cannot verify it from
1561
- // its own first-party-host code, so report skipped (honest, not fail).
1562
- checks.push({
1563
- id: "deployment-status",
1564
- status: "skipped",
1565
- description: "Deployment is run by your agent via the Vercel CLI; Jolly does not contact Vercel.",
1566
- command: "npx vercel",
1567
- });
1568
- }
1569
-
1570
- if (wants("stripe")) {
1571
- const hasPub = Boolean(
1572
- values["JOLLY_STRIPE_PUBLISHABLE_KEY"] ?? process.env["JOLLY_STRIPE_PUBLISHABLE_KEY"],
1573
- );
1574
- const hasSecret = Boolean(
1575
- values["JOLLY_STRIPE_SECRET_KEY"] ?? process.env["JOLLY_STRIPE_SECRET_KEY"],
1576
- );
1577
- checks.push({
1578
- id: "stripe-keys",
1579
- status: hasPub && hasSecret ? "pass" : "fail",
1580
- description:
1581
- hasPub && hasSecret ? "Stripe test-mode keys present in .env." : "Stripe keys not configured.",
1582
- command: hasPub && hasSecret ? undefined : "jolly create stripe --publishable-key <pk> --secret-key <sk>",
1583
- });
1584
- }
1585
-
1586
- const hasFail = checks.some((c) => c.status === "fail");
1587
- const hasWarn = checks.some((c) => c.status === "warning");
1588
- const status: EnvelopeStatus = hasFail ? "error" : hasWarn ? "warning" : "success";
1589
-
1590
- // Gather next steps from actionable checks.
1591
- const nextSteps: NextStep[] = checks
1592
- .filter((c) => (c.status === "fail" || c.status === "warning") && c.command)
1593
- .map((c) => ({ description: c.description ?? `Address ${c.id}.`, command: c.command }));
1594
-
1595
- return envelope({
1596
- command: group ? `doctor ${group}` : "doctor",
1597
- status,
1598
- summary:
1599
- status === "success"
1600
- ? "All performed checks passed."
1601
- : status === "warning"
1602
- ? "Some checks need attention."
1603
- : "Some checks failed; see next steps.",
1604
- data: { group: group ?? "all" },
1605
- checks,
1606
- nextSteps,
1607
- errors: hasFail
1608
- ? [
1609
- {
1610
- code: "DOCTOR_CHECKS_FAILED",
1611
- message: "One or more diagnostics failed.",
1612
- remediation: "Address the failing checks listed in nextSteps.",
1613
- },
1614
- ]
1615
- : [],
1616
- });
1617
- }
1618
-
1619
- // ─── skills (feature 006/001) ─────────────────────────────────────────────
1620
-
1621
- function commandSkills(args: ParsedArgs): Envelope {
1622
- const command = "skills";
1623
- const sub = args.positionals[1];
1624
-
1625
- if (sub === "install" || sub === "update") {
1626
- const checks: Check[] = DEFAULT_SKILLS.map((skill) => {
1627
- const already = skillInstalledOnDisk(skill);
1628
- if (!already && sub === "install") installSkill(skill);
1629
- const present = skillInstalledOnDisk(skill);
1630
- return {
1631
- id: `skill-${skill.id}`,
1632
- status: present ? "pass" : "fail",
1633
- description: present ? `${skill.id} present.` : `${skill.id} not verified on disk.`,
1634
- };
1635
- });
1636
- const failed = checks.filter((c) => c.status === "fail").map((c) => c.id);
1637
- return envelope({
1638
- command: `skills ${sub}`,
1639
- status: failed.length > 0 ? "warning" : "success",
1640
- summary:
1641
- failed.length > 0
1642
- ? `Some skills not verified: ${failed.join(", ")}.`
1643
- : `Skills ${sub === "install" ? "installed" : "checked"}.`,
1644
- data: { skills: DEFAULT_SKILLS.map((s) => s.id) },
1645
- checks,
1646
- });
1647
- }
1648
-
1649
- // Default: list/inspect the skill set.
1650
- const checks: Check[] = DEFAULT_SKILLS.map((skill) => {
1651
- const present = skillInstalledOnDisk(skill);
1652
- return {
1653
- id: `skill-${skill.id}`,
1654
- status: present ? "pass" : "unknown",
1655
- description: `${skill.description}${present ? " (installed)" : " (not installed)"}.`,
1656
- };
1657
- });
1658
-
1659
- return envelope({
1660
- command,
1661
- status: "success",
1662
- summary: `Jolly manages ${DEFAULT_SKILLS.length} skills (install via npx skills add).`,
1663
- data: {
1664
- skills: DEFAULT_SKILLS.map((s) => ({ id: s.id, ref: s.ref, description: s.description })),
1665
- },
1666
- checks,
1667
- nextSteps: [
1668
- {
1669
- description: "Run jolly init (or jolly start) to install the skill set.",
1670
- command: "jolly init",
1671
- },
1672
- ],
1673
- });
1674
- }
1675
-
1676
- // ─── upgrade (feature 017) ────────────────────────────────────────────────
1677
-
1678
- function commandUpgrade(_args: ParsedArgs): Envelope {
1679
- const command = "upgrade";
1680
- const checks: Check[] = DEFAULT_SKILLS.map((skill) => {
1681
- const present = skillInstalledOnDisk(skill);
1682
- return {
1683
- id: `skill-${skill.id}`,
1684
- status: present ? "pass" : "skipped",
1685
- description: present
1686
- ? `${skill.id} is managed; checked for updates.`
1687
- : `${skill.id} not installed; skipped.`,
1688
- };
1689
- });
1690
-
1691
- // Detect a cloned Paper storefront for plan-only baseline guidance.
1692
- const paperPresent = existsSync(join(projectDir(), "paper-version.json"));
1693
- checks.push({
1694
- id: "paper-baseline",
1695
- status: paperPresent ? "unknown" : "skipped",
1696
- description: paperPresent
1697
- ? "Paper storefront detected; Jolly plans Paper migrations but does not auto-apply them in v1."
1698
- : "No Paper storefront detected; nothing to plan.",
1699
- });
1700
-
1701
- return envelope({
1702
- command,
1703
- status: "success",
1704
- summary: "Checked Jolly-managed skills and guidance for updates; Paper changes are plan-only.",
1705
- data: {
1706
- skillsChecked: DEFAULT_SKILLS.map((s) => s.id),
1707
- paperBaselineDetected: paperPresent,
1708
- paperAutoApply: false,
1709
- },
1710
- checks,
1711
- nextSteps: paperPresent
1712
- ? [{ description: "Review the Paper upgrade plan before applying any migration manually." }]
1713
- : [],
1714
- });
1715
- }
1716
-
1717
- // ─── start (features 001/006) ─────────────────────────────────────────────
1718
-
1719
- interface PlanStage {
1720
- stage: string;
1721
- effects: {
1722
- directoriesCreated: string[];
1723
- filesWritten: string[];
1724
- networkHostsContacted: string[];
1725
- repositoriesCloned: string[];
1726
- };
1727
- riskContext?: RiskContext;
1728
- }
1729
-
1730
- function startPlan(): PlanStage[] {
1731
- return [
1732
- {
1733
- stage: "init",
1734
- effects: {
1735
- directoriesCreated: [".claude/skills"],
1736
- filesWritten: [".mcp.json", "AGENTS.md"],
1737
- networkHostsContacted: ["github.com"],
1738
- repositoriesCloned: [],
1739
- },
1740
- riskContext: {
1741
- action: "init",
1742
- target: "local project (skills, .mcp.json, AGENTS.md)",
1743
- riskLevel: "low",
1744
- categories: [],
1745
- reversible: true,
1746
- sideEffects: ["Installs skills, writes .mcp.json and AGENTS.md"],
1747
- dryRunAvailable: true,
1748
- },
1749
- },
1750
- {
1751
- stage: "auth",
1752
- effects: {
1753
- directoriesCreated: [],
1754
- filesWritten: [".env"],
1755
- networkHostsContacted: ["cloud.saleor.io", "auth.saleor.io"],
1756
- repositoriesCloned: [],
1757
- },
1758
- riskContext: {
1759
- action: "login",
1760
- target: cloudApiBase(),
1761
- riskLevel: "medium",
1762
- categories: ["credential handling"],
1763
- reversible: true,
1764
- sideEffects: ["Acquires and stores a Saleor Cloud token in .env"],
1765
- dryRunAvailable: true,
1766
- },
1767
- },
1768
- {
1769
- stage: "store",
1770
- effects: {
1771
- directoriesCreated: [],
1772
- filesWritten: [".env"],
1773
- networkHostsContacted: ["cloud.saleor.io"],
1774
- repositoriesCloned: [],
1775
- },
1776
- riskContext: createStoreRiskContext(
1777
- `${cloudApiBase()}/organizations/{organization}/environments/`,
1778
- ),
1779
- },
1780
- {
1781
- stage: "storefront",
1782
- effects: {
1783
- directoriesCreated: ["storefront"],
1784
- filesWritten: [],
1785
- networkHostsContacted: ["github.com"],
1786
- repositoriesCloned: ["saleor/storefront"],
1787
- },
1788
- riskContext: {
1789
- action: "clone storefront",
1790
- target: "saleor/storefront (Paper) → storefront/",
1791
- riskLevel: "low",
1792
- categories: [],
1793
- reversible: true,
1794
- sideEffects: ["Clones the Saleor Paper storefront repository into storefront/"],
1795
- dryRunAvailable: true,
1796
- },
1797
- },
1798
- {
1799
- stage: "deploy",
1800
- effects: {
1801
- directoriesCreated: [],
1802
- filesWritten: [],
1803
- networkHostsContacted: [],
1804
- repositoriesCloned: [],
1805
- },
1806
- },
1807
- ];
1808
- }
1809
-
1810
- function startPlaybook(): NextStep[] {
1811
- return [
1812
- {
1813
- description: "1. Bootstrap: jolly init installed skills, wrote .mcp.json, and ran doctor.",
1814
- command: "jolly init",
1815
- },
1816
- { description: "2. Authenticate Saleor Cloud.", command: "jolly login --token <value>" },
1817
- {
1818
- description: "3. Provision a Saleor Cloud store/environment.",
1819
- command: "jolly create store --create-environment",
1820
- },
1821
- { description: "4. Acquire a Saleor app token.", command: "jolly create app-token" },
1822
- {
1823
- description: "5. Clone the Paper storefront with git and install with pnpm, guided by the Jolly skill.",
1824
- command: "git clone https://github.com/saleor/storefront",
1825
- },
1826
- {
1827
- description: "6. Apply the Jolly starter recipe with @saleor/configurator, guided by the Jolly skill.",
1828
- },
1829
- {
1830
- description: "7. Deploy with the Vercel CLI under your own vercel login session.",
1831
- command: "npx vercel",
1832
- },
1833
- {
1834
- description: "8. Provide Stripe test-mode keys.",
1835
- command: "jolly create stripe --publishable-key <pk> --secret-key <sk>",
1836
- },
1837
- { description: "9. Verify operational readiness.", command: "jolly doctor" },
1838
- ];
1839
- }
1840
-
1841
- function commandStartDryRun(): Envelope {
1842
- const command = "start";
1843
- const plan = startPlan();
1844
- return envelope({
1845
- command,
1846
- status: "success",
1847
- summary: "Previewed the jolly start plan. No files were written and no network requests were made.",
1848
- data: {
1849
- dryRun: true,
1850
- plan,
1851
- },
1852
- checks: [
1853
- {
1854
- id: "start-dry-run",
1855
- status: "skipped",
1856
- description: "This is a dry-run preview; no stage was executed.",
1857
- },
1858
- ],
1859
- nextSteps: [
1860
- {
1861
- description: "Run jolly start to execute the plan and get the ordered playbook.",
1862
- command: "jolly start",
1863
- },
1864
- ],
1865
- });
1866
- }
1867
-
1868
- function commandStart(args: ParsedArgs): Envelope {
1869
- if (args.dryRun) return commandStartDryRun();
1870
-
1871
- const command = "start";
1872
-
1873
- // Bootstrap: run init (real, on-disk) + run doctor (read-only). Never
1874
- // fabricate stages the agent must perform.
1875
- const initEnv = commandInit(args);
1876
- const doctorEnv = commandDoctor({
1877
- ...args,
1878
- positionals: ["doctor"],
1879
- json: true,
1880
- dryRun: false,
1881
- });
1882
-
1883
- const checks: Check[] = [
1884
- ...initEnv.checks.map((c) => ({ ...c, id: `init-${c.id}` })),
1885
- ...doctorEnv.checks.map((c) => ({ ...c, id: `doctor-${c.id}` })),
1886
- ];
1887
-
1888
- // start never reports overall "success" for an end-to-end flow it did not
1889
- // complete: bootstrap may succeed, but downstream agent stages are pending.
1890
- const bootstrapFailed = initEnv.status === "error";
1891
- const status: EnvelopeStatus = bootstrapFailed ? "error" : "warning";
1892
-
1893
- return envelope({
1894
- command,
1895
- status,
1896
- summary: bootstrapFailed
1897
- ? "Bootstrap failed; see errors. No downstream stage was performed."
1898
- : "Bootstrap complete (skills, scaffold, doctor). Follow the playbook to finish setup.",
1899
- data: {
1900
- bootstrap: {
1901
- skillsInstalled: !bootstrapFailed,
1902
- mcpMerged: initEnv.data["mcpMerged"] ?? false,
1903
- agentsMdMerged: initEnv.data["agentsMdMerged"] ?? false,
1904
- doctorRan: true,
1905
- },
1906
- playbook: startPlaybook().map((s) => s.description),
1907
- pendingStages: ["storefront", "recipe", "deploy"],
1908
- },
1909
- checks,
1910
- nextSteps: startPlaybook(),
1911
- errors: bootstrapFailed ? initEnv.errors : [],
1912
- });
1913
- }
1914
-
1915
- // ─── top-level help ───────────────────────────────────────────────────────
1916
-
1917
- function commandHelp(): Envelope {
1918
- return envelope({
1919
- command: "help",
1920
- status: "success",
1921
- summary:
1922
- "Jolly — Ahoy, agent. Go build a store. (a tool by Dmytri Kleiner; not an official Saleor/Vercel/Stripe product)",
1923
- data: {
1924
- commands: [
1925
- "login",
1926
- "logout",
1927
- "auth status",
1928
- "init",
1929
- "start",
1930
- "doctor",
1931
- "upgrade",
1932
- "skills",
1933
- "create store",
1934
- "create app-token",
1935
- "create stripe",
1936
- ],
1937
- globalFlags: ["--json", "--quiet", "--yes/-y", "--dry-run"],
1938
- },
1939
- nextSteps: [
1940
- {
1941
- description: "Run jolly start to bootstrap setup and get the ordered playbook.",
1942
- command: "jolly start",
1943
- },
1944
- ],
1945
- });
1946
- }
1947
-
1948
- // ─── dispatch ─────────────────────────────────────────────────────────────
1949
-
1950
- async function dispatch(args: ParsedArgs): Promise<Envelope> {
1951
- const cmd = args.positionals[0];
1952
-
1953
- switch (cmd) {
1954
- case undefined:
1955
- case "help":
1956
- return commandHelp();
1957
- case "login":
1958
- return commandLogin(args);
1959
- case "logout":
1960
- return commandLogout(args);
1961
- case "auth":
1962
- if (args.positionals[1] === "status") return commandAuthStatus(args);
1963
- return errorEnvelope("auth", `Unknown auth subcommand "${args.positionals[1] ?? ""}".`, [
1964
- {
1965
- code: "UNKNOWN_AUTH_SUBCOMMAND",
1966
- message: 'The only auth subcommand is "status".',
1967
- remediation: "Run `jolly auth status`.",
1968
- },
1969
- ]);
1970
- case "create":
1971
- return commandCreate(args);
1972
- case "init":
1973
- return commandInit(args);
1974
- case "start":
1975
- return commandStart(args);
1976
- case "doctor":
1977
- return commandDoctor(args);
1978
- case "upgrade":
1979
- return commandUpgrade(args);
1980
- case "skills":
1981
- return commandSkills(args);
1982
- default:
1983
- return errorEnvelope(cmd, `Unknown command "${cmd}".`, [
1984
- {
1985
- code: "UNKNOWN_COMMAND",
1986
- message: `"${cmd}" is not a Jolly command.`,
1987
- remediation: "Run `jolly help` to list available commands.",
1988
- },
1989
- ]);
1990
- }
1991
- }
1992
-
1993
- async function main(): Promise<void> {
1994
- const args = parseArgs(process.argv.slice(2));
1995
- let env: Envelope;
1996
- try {
1997
- env = await dispatch(args);
1998
- } catch (err) {
1999
- env = errorEnvelope(args.positionals[0] ?? "jolly", "An unexpected error occurred.", [
2000
- {
2001
- code: "UNEXPECTED_ERROR",
2002
- message: err instanceof Error ? err.message : String(err),
2003
- remediation: "Re-run with --json and report the error code.",
2004
- },
2005
- ]);
2006
- }
2007
- const exitCode = emit(env, args);
2008
- process.exit(exitCode);
2009
- }
2010
-
2011
- void main();