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