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