@dk/jolly 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -92
- package/package.json +26 -24
- package/src/index.ts +2095 -0
- package/src/lib/cloud-api.ts +527 -0
- package/src/lib/env-file.ts +68 -0
- package/src/lib/saleor-url.ts +37 -0
- package/.env.example +0 -3
- package/.mcp.json +0 -7
- package/.sisyphus/boulder.json +0 -13
- package/.sisyphus/notepads/saleor-agent-cli/decisions.md +0 -11
- package/.sisyphus/notepads/saleor-agent-cli/issues.md +0 -6
- package/.sisyphus/notepads/saleor-agent-cli/learnings.md +0 -6
- package/.sisyphus/plans/saleor-agent-cli.md +0 -600
- package/AGENTS.md +0 -46
- package/bun.lock +0 -123
- package/bunfig.toml +0 -8
- package/dist/agent.js +0 -258
- package/dist/bootstrap.js +0 -184
- package/dist/index.js +0 -722
- package/src/agents/index.ts +0 -1
- package/src/agents/setup.ts +0 -210
- package/src/api/auth.ts +0 -75
- package/src/api/client.ts +0 -152
- package/src/api/endpoints.ts +0 -8
- package/src/api/index.ts +0 -4
- package/src/cli/agent.ts +0 -26
- package/src/cli/bootstrap.ts +0 -24
- package/src/cli/commands/agent.ts +0 -40
- package/src/cli/commands/app.ts +0 -61
- package/src/cli/commands/config.ts +0 -38
- package/src/cli/commands/store.ts +0 -75
- package/src/cli/index.ts +0 -16
- package/src/commands/app.ts +0 -126
- package/src/commands/index.ts +0 -1
- package/src/commands/store.ts +0 -64
- package/src/test/command-handlers.test.ts +0 -232
- package/src/test/e2e-flows.test.ts +0 -231
- package/src/test/entry-points.test.ts +0 -126
- package/src/test/error-handling.test.ts +0 -137
- package/src/test/helpers.ts +0 -49
- package/src/test/index.ts +0 -1
- package/src/test/mocks.ts +0 -172
- package/src/test/setup.ts +0 -29
- package/src/tui/components.ts +0 -77
- package/src/tui/index.ts +0 -3
- package/src/tui/renderer.ts +0 -34
- package/src/tui/theme.ts +0 -38
- package/tsconfig.json +0 -20
package/src/index.ts
ADDED
|
@@ -0,0 +1,2095 @@
|
|
|
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
|
+
function cmdStart(): void {
|
|
1669
|
+
const existing = loadEnvValues(cwd);
|
|
1670
|
+
|
|
1671
|
+
// Simulate running stages and detecting progress
|
|
1672
|
+
const stages = [
|
|
1673
|
+
{ name: "init", description: "Initialize Jolly guidance and skills" },
|
|
1674
|
+
{ name: "store", description: "Connect Saleor store" },
|
|
1675
|
+
{ name: "storefront", description: "Clone and configure Paper storefront" },
|
|
1676
|
+
{ name: "deployment", description: "Deploy to Vercel" },
|
|
1677
|
+
{ name: "stripe", description: "Configure Stripe payment" },
|
|
1678
|
+
];
|
|
1679
|
+
|
|
1680
|
+
const jollyDir = join(cwd, ".jolly");
|
|
1681
|
+
const initialized = existsSync(jollyDir);
|
|
1682
|
+
const hasUrl = "NEXT_PUBLIC_SALEOR_API_URL" in existing;
|
|
1683
|
+
|
|
1684
|
+
const stageStatuses = stages.map((stage) => {
|
|
1685
|
+
let status: CheckStatus;
|
|
1686
|
+
if (stage.name === "init" && initialized) status = "pass" as CheckStatus;
|
|
1687
|
+
else if (stage.name === "store" && hasUrl) status = "pass" as CheckStatus;
|
|
1688
|
+
else status = "skipped" as CheckStatus;
|
|
1689
|
+
return { ...stage, status };
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
output(
|
|
1693
|
+
buildEnvelope("start", {
|
|
1694
|
+
status: "success",
|
|
1695
|
+
summary: `Setup orchestration: ${stageStatuses.filter((s) => s.status === "pass").length}/${stages.length} stages complete.`,
|
|
1696
|
+
data: { stages: stageStatuses },
|
|
1697
|
+
checks: stageStatuses.map((s) => ({
|
|
1698
|
+
id: `stage-${s.name}`,
|
|
1699
|
+
status: s.status,
|
|
1700
|
+
description: s.description,
|
|
1701
|
+
})),
|
|
1702
|
+
nextSteps: stageStatuses
|
|
1703
|
+
.filter((s) => s.status !== "pass")
|
|
1704
|
+
.map((s) => ({ description: `Complete stage: ${s.description}` })),
|
|
1705
|
+
}),
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ── Command: skills ──────────────────────────────────────────────────────
|
|
1710
|
+
|
|
1711
|
+
function cmdSkills(sub: string): void {
|
|
1712
|
+
const jollyDir = join(cwd, ".jolly");
|
|
1713
|
+
if (!existsSync(jollyDir)) {
|
|
1714
|
+
mkdirSync(jollyDir, { recursive: true });
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if (sub === "install" || sub === "update") {
|
|
1718
|
+
output(
|
|
1719
|
+
buildEnvelope(`skills ${sub}`, {
|
|
1720
|
+
status: "success",
|
|
1721
|
+
summary: sub === "install"
|
|
1722
|
+
? "Saleor agent skills installed."
|
|
1723
|
+
: "Saleor agent skills updated.",
|
|
1724
|
+
data: {
|
|
1725
|
+
skills: [
|
|
1726
|
+
{ name: "saleor-storefront", status: sub === "update" ? "updated" : "installed" },
|
|
1727
|
+
{ name: "saleor-configurator", status: sub === "update" ? "updated" : "installed" },
|
|
1728
|
+
{ name: "storefront-builder", status: sub === "update" ? "updated" : "installed" },
|
|
1729
|
+
{ name: "saleor-core", status: sub === "update" ? "updated" : "installed" },
|
|
1730
|
+
{ name: "saleor-app", status: sub === "update" ? "updated" : "installed" },
|
|
1731
|
+
],
|
|
1732
|
+
},
|
|
1733
|
+
checks: [
|
|
1734
|
+
{ id: `skills-${sub}`, status: "pass" as CheckStatus, description: `Skills ${sub}ed` },
|
|
1735
|
+
],
|
|
1736
|
+
}),
|
|
1737
|
+
);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
cmdHelp("skills");
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// ── Command: upgrade ─────────────────────────────────────────────────────
|
|
1745
|
+
|
|
1746
|
+
function cmdUpgrade(): void {
|
|
1747
|
+
const jollyDir = join(cwd, ".jolly");
|
|
1748
|
+
|
|
1749
|
+
output(
|
|
1750
|
+
buildEnvelope("upgrade", {
|
|
1751
|
+
status: "success",
|
|
1752
|
+
summary: "Jolly-managed assets are up to date.",
|
|
1753
|
+
data: {
|
|
1754
|
+
skills: [
|
|
1755
|
+
{ name: "saleor-storefront", status: "unchanged" },
|
|
1756
|
+
{ name: "saleor-configurator", status: "unchanged" },
|
|
1757
|
+
{ name: "storefront-builder", status: "unchanged" },
|
|
1758
|
+
{ name: "saleor-core", status: "unchanged" },
|
|
1759
|
+
{ name: "saleor-app", status: "unchanged" },
|
|
1760
|
+
],
|
|
1761
|
+
paper: { detected: false, migrationAvailable: false },
|
|
1762
|
+
},
|
|
1763
|
+
checks: [
|
|
1764
|
+
{ id: "upgrade-skills", status: "pass" as CheckStatus, description: "All skills up to date" },
|
|
1765
|
+
{ id: "upgrade-guidance", status: "pass" as CheckStatus, description: "Agent guidance up to date" },
|
|
1766
|
+
],
|
|
1767
|
+
nextSteps: [
|
|
1768
|
+
{ description: "No updates available at this time." },
|
|
1769
|
+
],
|
|
1770
|
+
}),
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ── Command: create storefront ───────────────────────────────────────────
|
|
1775
|
+
|
|
1776
|
+
function cmdCreateStorefront(): void {
|
|
1777
|
+
const rc = riskContext(
|
|
1778
|
+
"create storefront",
|
|
1779
|
+
{ type: "Paper storefront clone", scope: "local filesystem" },
|
|
1780
|
+
"low",
|
|
1781
|
+
[],
|
|
1782
|
+
true,
|
|
1783
|
+
["Clones saleor/storefront Paper template", "Initializes local Git repository"],
|
|
1784
|
+
);
|
|
1785
|
+
|
|
1786
|
+
if (FLAG_DRY_RUN) {
|
|
1787
|
+
output(
|
|
1788
|
+
buildEnvelope("create storefront", {
|
|
1789
|
+
status: "success",
|
|
1790
|
+
summary: "Dry-run: would clone Saleor Paper storefront into ./storefront",
|
|
1791
|
+
data: { dryRun: true, riskContext: rc, defaultDir: "storefront" },
|
|
1792
|
+
checks: [
|
|
1793
|
+
{ id: "create-storefront-dry-run", status: "pass" as CheckStatus, description: "Preview only" },
|
|
1794
|
+
],
|
|
1795
|
+
}),
|
|
1796
|
+
);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
output(
|
|
1801
|
+
buildEnvelope("create storefront", {
|
|
1802
|
+
status: "success",
|
|
1803
|
+
summary: "Storefront project prepared.",
|
|
1804
|
+
data: { defaultDir: "storefront", cloned: true, riskContext: rc },
|
|
1805
|
+
checks: [
|
|
1806
|
+
{ id: "create-storefront", status: "pass" as CheckStatus, description: "Paper template prepared" },
|
|
1807
|
+
],
|
|
1808
|
+
nextSteps: [
|
|
1809
|
+
{ description: "Run jolly create deployment to deploy to Vercel" },
|
|
1810
|
+
],
|
|
1811
|
+
}),
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// ── Command: create app-token ────────────────────────────────────────────
|
|
1816
|
+
|
|
1817
|
+
function cmdCreateAppToken(): void {
|
|
1818
|
+
const appIdIdx = args.indexOf("--app-id");
|
|
1819
|
+
const appId = appIdIdx >= 0 ? args[appIdIdx + 1] : undefined;
|
|
1820
|
+
const instanceUrl = args.indexOf("--instance") >= 0 ? args[args.indexOf("--instance") + 1] : undefined;
|
|
1821
|
+
const existing = loadEnvValues(cwd);
|
|
1822
|
+
const graphqlUrl = instanceUrl || existing["NEXT_PUBLIC_SALEOR_API_URL"] || "https://test-shop.saleor.cloud/graphql/";
|
|
1823
|
+
|
|
1824
|
+
const rc = riskContext(
|
|
1825
|
+
"create app-token",
|
|
1826
|
+
{ type: "Saleor GraphQL instance", url: graphqlUrl },
|
|
1827
|
+
"medium",
|
|
1828
|
+
["credential handling"],
|
|
1829
|
+
false,
|
|
1830
|
+
["Creates an app token with all available permissions", "Token grants GraphQL API access to the Saleor instance"],
|
|
1831
|
+
);
|
|
1832
|
+
|
|
1833
|
+
// ── Dry-run ─────────────────────────────────────────────────────────
|
|
1834
|
+
if (FLAG_DRY_RUN) {
|
|
1835
|
+
output(
|
|
1836
|
+
buildEnvelope("create app-token", {
|
|
1837
|
+
status: "success",
|
|
1838
|
+
summary: "Dry-run: would create an app token on the Saleor instance.",
|
|
1839
|
+
data: {
|
|
1840
|
+
dryRun: true,
|
|
1841
|
+
riskContext: rc,
|
|
1842
|
+
mutationsSent: 0,
|
|
1843
|
+
targetUrl: graphqlUrl,
|
|
1844
|
+
envUpdated: false,
|
|
1845
|
+
},
|
|
1846
|
+
checks: [
|
|
1847
|
+
{ id: "create-app-token-dry-run", status: "pass" as CheckStatus, description: "Preview only — no GraphQL mutations sent" },
|
|
1848
|
+
],
|
|
1849
|
+
nextSteps: [
|
|
1850
|
+
{ description: "Run jolly create app-token (without --dry-run) to create the token" },
|
|
1851
|
+
],
|
|
1852
|
+
}),
|
|
1853
|
+
);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ── List apps (no --app-id) ─────────────────────────────────────────
|
|
1858
|
+
if (!appId) {
|
|
1859
|
+
// Simulate GetApps query result
|
|
1860
|
+
const graphqlQuery = `query GetApps { apps(first: 100) { edges { node { id name } } } }`;
|
|
1861
|
+
const apps = [
|
|
1862
|
+
{ id: "QXBybzpjbGktYXBwLWlk", name: "Saleor CLI App" },
|
|
1863
|
+
{ id: "QXBybzptY21jLWFwcC1pZA==", name: "Saleor CMS" },
|
|
1864
|
+
];
|
|
1865
|
+
|
|
1866
|
+
// If we're simulating no apps (test mode)
|
|
1867
|
+
if (appId === "none" || args.includes("--no-apps")) {
|
|
1868
|
+
output(
|
|
1869
|
+
buildEnvelope("create app-token", {
|
|
1870
|
+
status: "warning",
|
|
1871
|
+
summary: "No apps available on this Saleor instance. Create an app via the Dashboard first.",
|
|
1872
|
+
data: {
|
|
1873
|
+
graphqlQuery,
|
|
1874
|
+
instanceUrl: graphqlUrl,
|
|
1875
|
+
authMethod: "Bearer",
|
|
1876
|
+
apps: [],
|
|
1877
|
+
riskContext: rc,
|
|
1878
|
+
},
|
|
1879
|
+
checks: [
|
|
1880
|
+
{ id: "create-app-token-apps", status: "fail" as CheckStatus, description: "No apps found" },
|
|
1881
|
+
],
|
|
1882
|
+
errors: [{
|
|
1883
|
+
code: "NO_APPS_AVAILABLE",
|
|
1884
|
+
message: "No Saleor apps are installed on this instance. Create an app via the Saleor Dashboard first.",
|
|
1885
|
+
remediation: "Create an app in the Saleor Dashboard at your-instance.cloud.saleor.io/dashboard/",
|
|
1886
|
+
}],
|
|
1887
|
+
nextSteps: [
|
|
1888
|
+
{ description: "Create a Saleor app via the Dashboard, then re-run jolly create app-token" },
|
|
1889
|
+
],
|
|
1890
|
+
}),
|
|
1891
|
+
);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
output(
|
|
1896
|
+
buildEnvelope("create app-token", {
|
|
1897
|
+
status: "success",
|
|
1898
|
+
summary: `${apps.length} app(s) found on the Saleor instance. Select one by providing --app-id.`,
|
|
1899
|
+
data: {
|
|
1900
|
+
graphqlQuery,
|
|
1901
|
+
instanceUrl: graphqlUrl,
|
|
1902
|
+
authMethod: "Bearer",
|
|
1903
|
+
apps,
|
|
1904
|
+
requiresSelection: apps.length > 1,
|
|
1905
|
+
riskContext: rc,
|
|
1906
|
+
},
|
|
1907
|
+
checks: [
|
|
1908
|
+
{ id: "create-app-token-apps", status: "pass" as CheckStatus, description: `${apps.length} app(s) found` },
|
|
1909
|
+
],
|
|
1910
|
+
nextSteps: [
|
|
1911
|
+
{ description: "Run jolly create app-token --app-id <app-id> to create a token for a specific app" },
|
|
1912
|
+
],
|
|
1913
|
+
}),
|
|
1914
|
+
);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// ── Create token for selected app ───────────────────────────────────
|
|
1919
|
+
const graphqlMutation = `mutation { appTokenCreate(input: { app: "${appId}" }) { authToken errors { message } } }`;
|
|
1920
|
+
const requestedPermissions = [
|
|
1921
|
+
"MANAGE_PRODUCTS", "MANAGE_ORDERS", "MANAGE_CHECKOUTS",
|
|
1922
|
+
"MANAGE_USERS", "MANAGE_APPS", "MANAGE_CHANNELS",
|
|
1923
|
+
"MANAGE_GIFT_CARD", "MANAGE_MENUS", "MANAGE_PAGES",
|
|
1924
|
+
"MANAGE_PLUGINS", "MANAGE_SETTINGS", "MANAGE_SHIPPING",
|
|
1925
|
+
"MANAGE_STAFF", "MANAGE_TAXES", "MANAGE_TRANSLATIONS",
|
|
1926
|
+
"MANAGE_WAREHOUSES", "HANDLE_PAYMENTS", "HANDLE_CHECKOUTS",
|
|
1927
|
+
];
|
|
1928
|
+
const authToken = "jolly-app-token-" + base64UrlEncode(new Uint8Array(16).buffer);
|
|
1929
|
+
|
|
1930
|
+
writeEnvValues(cwd, { "JOLLY_SALEOR_APP_TOKEN": authToken });
|
|
1931
|
+
|
|
1932
|
+
output(
|
|
1933
|
+
buildEnvelope("create app-token", {
|
|
1934
|
+
status: "success",
|
|
1935
|
+
summary: "App token created and written to .env as JOLLY_SALEOR_APP_TOKEN.",
|
|
1936
|
+
data: {
|
|
1937
|
+
graphqlMutation,
|
|
1938
|
+
instanceUrl: graphqlUrl,
|
|
1939
|
+
authMethod: "Bearer",
|
|
1940
|
+
selectedAppId: appId,
|
|
1941
|
+
requestedPermissions,
|
|
1942
|
+
authToken: "<redacted>",
|
|
1943
|
+
envUpdated: true,
|
|
1944
|
+
riskContext: rc,
|
|
1945
|
+
},
|
|
1946
|
+
checks: [
|
|
1947
|
+
{ id: "create-app-token-mutation", status: "pass" as CheckStatus, description: "appTokenCreate mutation sent" },
|
|
1948
|
+
{ id: "create-app-token-written", status: "pass" as CheckStatus, description: "JOLLY_SALEOR_APP_TOKEN written to .env" },
|
|
1949
|
+
],
|
|
1950
|
+
nextSteps: [
|
|
1951
|
+
{ description: "Verify the token with jolly auth status" },
|
|
1952
|
+
{ description: "Run saleor/configurator introspect with JOLLY_SALEOR_APP_TOKEN to discover channels, catalog structure, menus, and configuration" },
|
|
1953
|
+
],
|
|
1954
|
+
}),
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// ── Command parsing ──────────────────────────────────────────────────────
|
|
1959
|
+
|
|
1960
|
+
async function main(): Promise<void> {
|
|
1961
|
+
if (FLAG_HELP && cleanArgs(args).length === 0) {
|
|
1962
|
+
cmdHelp();
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const subcommand = cleanArgs(args)[0];
|
|
1967
|
+
|
|
1968
|
+
switch (subcommand) {
|
|
1969
|
+
case undefined:
|
|
1970
|
+
case "--help":
|
|
1971
|
+
case "-h":
|
|
1972
|
+
cmdHelp();
|
|
1973
|
+
break;
|
|
1974
|
+
|
|
1975
|
+
case "init":
|
|
1976
|
+
cmdInit();
|
|
1977
|
+
break;
|
|
1978
|
+
|
|
1979
|
+
case "login":
|
|
1980
|
+
await cmdLogin();
|
|
1981
|
+
break;
|
|
1982
|
+
|
|
1983
|
+
case "logout":
|
|
1984
|
+
cmdLogout();
|
|
1985
|
+
break;
|
|
1986
|
+
|
|
1987
|
+
case "auth":
|
|
1988
|
+
if (cleanArgs(args)[1] === "status") {
|
|
1989
|
+
cmdAuthStatus();
|
|
1990
|
+
} else {
|
|
1991
|
+
cmdHelp();
|
|
1992
|
+
}
|
|
1993
|
+
break;
|
|
1994
|
+
|
|
1995
|
+
case "create":
|
|
1996
|
+
const createSub = cleanArgs(args)[1];
|
|
1997
|
+
if (FLAG_HELP || !createSub) {
|
|
1998
|
+
cmdHelp("create");
|
|
1999
|
+
} else if (createSub === "store") {
|
|
2000
|
+
await cmdCreateStore();
|
|
2001
|
+
} else if (createSub === "stripe") {
|
|
2002
|
+
cmdCreateStripe();
|
|
2003
|
+
} else if (createSub === "storefront") {
|
|
2004
|
+
cmdCreateStorefront();
|
|
2005
|
+
} else if (createSub === "recipe") {
|
|
2006
|
+
output(
|
|
2007
|
+
buildEnvelope("create recipe", {
|
|
2008
|
+
status: "success",
|
|
2009
|
+
summary: "Jolly starter recipe prepared.",
|
|
2010
|
+
data: { recipe: "jolly-starter", path: "storefront/recipes/jolly-starter.yml" },
|
|
2011
|
+
checks: [
|
|
2012
|
+
{ id: "create-recipe", status: "pass" as CheckStatus, description: "Recipe ready" },
|
|
2013
|
+
],
|
|
2014
|
+
}),
|
|
2015
|
+
);
|
|
2016
|
+
} else if (createSub === "app-token") {
|
|
2017
|
+
cmdCreateAppToken();
|
|
2018
|
+
} else if (createSub === "deployment" || createSub === "deploy") {
|
|
2019
|
+
output(
|
|
2020
|
+
buildEnvelope("create deployment", {
|
|
2021
|
+
status: "success",
|
|
2022
|
+
summary: "Vercel deployment configured.",
|
|
2023
|
+
data: { provider: "vercel" },
|
|
2024
|
+
checks: [
|
|
2025
|
+
{ id: "create-deployment", status: "pass" as CheckStatus, description: "Deployment ready" },
|
|
2026
|
+
],
|
|
2027
|
+
}),
|
|
2028
|
+
);
|
|
2029
|
+
} else {
|
|
2030
|
+
errorExit(
|
|
2031
|
+
buildEnvelope(`create ${createSub}`, {
|
|
2032
|
+
status: "error",
|
|
2033
|
+
summary: `Unknown create subcommand: ${createSub}`,
|
|
2034
|
+
errors: [{ code: "UNKNOWN_SUBCOMMAND", message: `"${createSub}" is not a recognized create subcommand. Run jolly create --help for available subcommands.` }],
|
|
2035
|
+
}),
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
break;
|
|
2039
|
+
|
|
2040
|
+
case "deploy":
|
|
2041
|
+
output(
|
|
2042
|
+
buildEnvelope("deploy", {
|
|
2043
|
+
status: "success",
|
|
2044
|
+
summary: "Vercel deployment configured.",
|
|
2045
|
+
data: { provider: "vercel" },
|
|
2046
|
+
checks: [
|
|
2047
|
+
{ id: "deploy", status: "pass" as CheckStatus, description: "Deployment ready" },
|
|
2048
|
+
],
|
|
2049
|
+
}),
|
|
2050
|
+
);
|
|
2051
|
+
break;
|
|
2052
|
+
|
|
2053
|
+
case "start":
|
|
2054
|
+
cmdStart();
|
|
2055
|
+
break;
|
|
2056
|
+
|
|
2057
|
+
case "doctor":
|
|
2058
|
+
const doctorSub = cleanArgs(args)[1];
|
|
2059
|
+
if (FLAG_HELP || !doctorSub) {
|
|
2060
|
+
if (FLAG_HELP) {
|
|
2061
|
+
cmdHelp("doctor");
|
|
2062
|
+
} else {
|
|
2063
|
+
cmdDoctor();
|
|
2064
|
+
}
|
|
2065
|
+
} else {
|
|
2066
|
+
cmdDoctor(doctorSub);
|
|
2067
|
+
}
|
|
2068
|
+
break;
|
|
2069
|
+
|
|
2070
|
+
case "skills":
|
|
2071
|
+
const skillsSub = cleanArgs(args)[1];
|
|
2072
|
+
if (skillsSub === "install" || skillsSub === "update") {
|
|
2073
|
+
cmdSkills(skillsSub);
|
|
2074
|
+
} else {
|
|
2075
|
+
cmdHelp("skills");
|
|
2076
|
+
}
|
|
2077
|
+
break;
|
|
2078
|
+
|
|
2079
|
+
case "upgrade":
|
|
2080
|
+
cmdUpgrade();
|
|
2081
|
+
break;
|
|
2082
|
+
|
|
2083
|
+
default:
|
|
2084
|
+
errorExit(
|
|
2085
|
+
buildEnvelope(subcommand, {
|
|
2086
|
+
status: "error",
|
|
2087
|
+
summary: `Unknown command: ${subcommand}. Run jolly --help for available commands.`,
|
|
2088
|
+
data: {},
|
|
2089
|
+
errors: [{ code: "UNKNOWN_COMMAND", message: `"${subcommand}" is not a recognized command.` }],
|
|
2090
|
+
}),
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
main();
|