@dk/jolly 0.1.11 → 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 +3 -2
- package/src/index.ts +1744 -1983
- package/src/lib/cloud-api.ts +38 -10
package/src/index.ts
CHANGED
|
@@ -1,2250 +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
|
-
//
|
|
185
|
+
// ─── Output rendering ─────────────────────────────────────────────────────
|
|
226
186
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"saleor-storefront",
|
|
232
|
-
"saleor-configurator",
|
|
233
|
-
"storefront-builder",
|
|
234
|
-
"saleor-core",
|
|
235
|
-
"saleor-app",
|
|
236
|
-
] as const;
|
|
237
|
-
|
|
238
|
-
function jollyAgentsSection(): string {
|
|
239
|
-
return `${JOLLY_AGENTS_BEGIN}
|
|
240
|
-
## Jolly (Saleor agent setup)
|
|
241
|
-
|
|
242
|
-
Jolly has initialized Saleor agent guidance in this project. Installed skills
|
|
243
|
-
live under \`.jolly/skills/\`:
|
|
244
|
-
|
|
245
|
-
${DEFAULT_SKILLS.map((s) => `- \`${s}\` — \`.jolly/skills/${s}/SKILL.md\``).join("\n")}
|
|
246
|
-
|
|
247
|
-
- Run \`npx @saleor/jolly start\` for end-to-end store setup.
|
|
248
|
-
- Live store data access: the read-only Saleor MCP server (https://mcp.saleor.app)
|
|
249
|
-
provides products, orders, and customers for a configured store.
|
|
250
|
-
- \`.mcp.json\` configures an mcp-graphql server (\`saleor-graphql\`) against your
|
|
251
|
-
Saleor GraphQL endpoint; it reads \`NEXT_PUBLIC_SALEOR_API_URL\` and
|
|
252
|
-
\`SALEOR_APP_TOKEN\` from the environment — no secrets are stored in the file.
|
|
253
|
-
${JOLLY_AGENTS_END}`;
|
|
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
|
-
}
|
|
369
|
-
}
|
|
370
|
-
if (missing.length > 0) {
|
|
371
|
-
process.stderr.write(
|
|
372
|
-
`jolly init: skill verification failed for: ${missing.join(", ")}\n`,
|
|
373
|
-
);
|
|
374
|
-
errorExit(
|
|
375
|
-
buildEnvelope("init", {
|
|
376
|
-
status: "error",
|
|
377
|
-
summary: `Skill verification failed: ${missing.join(", ")} not found on disk after install.`,
|
|
378
|
-
data: { existing: existingInit, initialized: false, skills, missingSkills: missing },
|
|
379
|
-
checks,
|
|
380
|
-
errors: [{ code: "SKILL_VERIFY_FAILED", message: `Skills not found on disk after install: ${missing.join(", ")}` }],
|
|
381
|
-
}),
|
|
382
|
-
);
|
|
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");
|
|
383
236
|
}
|
|
384
|
-
|
|
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) ─────────────────────────────
|
|
485
279
|
|
|
486
|
-
|
|
487
|
-
const hasBrowser = args.includes("--browser");
|
|
488
|
-
const exchangeCodeIdx = args.indexOf("--exchange-code");
|
|
489
|
-
const hasExchangeCode = exchangeCodeIdx >= 0;
|
|
490
|
-
const exchangeCodeValue = hasExchangeCode ? args[exchangeCodeIdx + 1] : undefined;
|
|
491
|
-
const tokenIdx = args.indexOf("--token");
|
|
492
|
-
const tokenValue = tokenIdx >= 0 ? args[tokenIdx + 1] : token;
|
|
280
|
+
const TOKEN_PAGE = "https://cloud.saleor.io/tokens";
|
|
493
281
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|
|
498
293
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
294
|
+
async function commandLogin(args: ParsedArgs): Promise<Envelope> {
|
|
295
|
+
const command = "login";
|
|
296
|
+
const token = args.options["token"];
|
|
297
|
+
const browser = args.flags.has("browser");
|
|
298
|
+
|
|
299
|
+
// --browser flows (PKCE preview, or honest unavailability) -------------
|
|
300
|
+
if (browser) {
|
|
301
|
+
if (args.dryRun) {
|
|
302
|
+
return loginBrowserDryRun(command);
|
|
303
|
+
}
|
|
304
|
+
// Real browser/Playwright callback flow is not implemented on this VM.
|
|
305
|
+
return errorEnvelope(
|
|
306
|
+
command,
|
|
307
|
+
"Browser-based login is not available in this environment.",
|
|
308
|
+
[
|
|
309
|
+
{
|
|
310
|
+
code: "BROWSER_LOGIN_UNAVAILABLE",
|
|
311
|
+
message:
|
|
312
|
+
"No native browser or Playwright callback flow is available to complete browser OAuth.",
|
|
313
|
+
remediation: `Create a token at ${TOKEN_PAGE} and run \`jolly login --token <value>\`.`,
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
579
|
+
return envelope({
|
|
580
|
+
command,
|
|
581
|
+
status: "success",
|
|
582
|
+
summary:
|
|
583
|
+
removed.length > 0
|
|
584
|
+
? `Removed Jolly-managed Saleor auth values from .env (${[...new Set(removed)].join(", ")}).`
|
|
585
|
+
: "No Jolly-managed Saleor auth values were present in .env.",
|
|
586
|
+
data: {
|
|
587
|
+
removed: [...new Set(removed)],
|
|
588
|
+
preservedOthers: true,
|
|
589
|
+
},
|
|
590
|
+
checks: [
|
|
591
|
+
{
|
|
592
|
+
id: "auth-cleared",
|
|
593
|
+
status: "pass",
|
|
594
|
+
description: "Jolly-managed Saleor auth values are no longer in .env.",
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
nextSteps: [
|
|
598
|
+
{
|
|
599
|
+
description: "Run jolly login to authenticate again when needed.",
|
|
600
|
+
command: "jolly login --token <value>",
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─── auth status (feature 018) ────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
function commandAuthStatus(_args: ParsedArgs): Envelope {
|
|
609
|
+
const command = "auth status";
|
|
610
|
+
const values = loadEnvValues(projectDir());
|
|
611
|
+
const hasCloudToken = Boolean(values["JOLLY_SALEOR_CLOUD_TOKEN"]);
|
|
612
|
+
const hasAppToken = Boolean(values["JOLLY_SALEOR_APP_TOKEN"]);
|
|
613
|
+
const org = values["JOLLY_SALEOR_ORGANIZATION"];
|
|
614
|
+
const accountContext = org && org.length > 0 ? org : "unknown";
|
|
615
|
+
|
|
616
|
+
const checks: Check[] = [
|
|
617
|
+
{
|
|
618
|
+
id: "cloud-token-configured",
|
|
619
|
+
status: hasCloudToken ? "pass" : "warning",
|
|
620
|
+
description: hasCloudToken
|
|
621
|
+
? "JOLLY_SALEOR_CLOUD_TOKEN is configured in .env."
|
|
622
|
+
: "JOLLY_SALEOR_CLOUD_TOKEN is not configured.",
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: "app-token-configured",
|
|
626
|
+
status: hasAppToken ? "pass" : "skipped",
|
|
627
|
+
description: hasAppToken
|
|
628
|
+
? "JOLLY_SALEOR_APP_TOKEN is configured in .env."
|
|
629
|
+
: "JOLLY_SALEOR_APP_TOKEN is not configured.",
|
|
630
|
+
},
|
|
631
|
+
];
|
|
632
|
+
|
|
633
|
+
return envelope({
|
|
634
|
+
command,
|
|
635
|
+
status: "success",
|
|
636
|
+
summary: hasCloudToken
|
|
637
|
+
? `Saleor Cloud authentication is configured (account context: ${accountContext}).`
|
|
638
|
+
: "Saleor Cloud authentication is not configured.",
|
|
639
|
+
data: {
|
|
640
|
+
hasCloudToken,
|
|
641
|
+
hasAppToken,
|
|
642
|
+
accountContext,
|
|
643
|
+
},
|
|
644
|
+
checks,
|
|
645
|
+
nextSteps: hasCloudToken
|
|
646
|
+
? []
|
|
647
|
+
: [
|
|
648
|
+
{
|
|
649
|
+
description: "Run jolly login to configure Saleor Cloud authentication.",
|
|
650
|
+
command: "jolly login --token <value>",
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── create store (features 012/024) ──────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
function createStoreRiskContext(target: unknown, dryRunAvailable = true): RiskContext {
|
|
659
|
+
return {
|
|
660
|
+
action: "create store",
|
|
661
|
+
target,
|
|
662
|
+
riskLevel: "medium",
|
|
663
|
+
categories: ["billing", "production configuration changes"],
|
|
664
|
+
reversible: false,
|
|
665
|
+
sideEffects: [
|
|
666
|
+
"Creates a Saleor Cloud project and/or environment",
|
|
667
|
+
"Writes NEXT_PUBLIC_SALEOR_API_URL and JOLLY_SALEOR_APP_TOKEN to .env",
|
|
668
|
+
],
|
|
669
|
+
dryRunAvailable,
|
|
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
|
-
|
|
1061
|
-
// ── Endpoint validation (--validate) ─────────────────────────────────────
|
|
1062
|
-
// Live introspection-style GraphQL validation: POST a minimal query and
|
|
1063
|
-
// require a JSON GraphQL response. Network failures (DNS, refused
|
|
1064
|
-
// connections) are caught and reported, never thrown (feature 012).
|
|
1065
863
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
message: string;
|
|
1070
|
-
}
|
|
864
|
+
const resolvedTarget = `${cloudApiBase()}/organizations/${selectedOrg}/environments/`;
|
|
865
|
+
const effectiveName = name ?? "jolly-store";
|
|
866
|
+
const effectiveDomainLabel = domainLabel ?? effectiveName;
|
|
1071
867
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
const matches = environments.filter(
|
|
1146
|
-
(e) => typeof e.domain === "string" && hostOf(e.domain as string) === endpointHost,
|
|
1147
|
-
);
|
|
1148
|
-
const matched = matches.length === 1;
|
|
1149
|
-
return {
|
|
1150
|
-
organizations: organizations.map((organization) => ({
|
|
1151
|
-
slug: String(organization.slug),
|
|
1152
|
-
name: organization.name !== undefined ? String(organization.name) : undefined,
|
|
1153
|
-
})),
|
|
1154
|
-
environments,
|
|
1155
|
-
matched,
|
|
1156
|
-
matchedDomain: matched ? matches[0].domain : undefined,
|
|
1157
|
-
organizationSlug: matched ? matches[0].organizationSlug : undefined,
|
|
1158
|
-
environmentKey: matched ? matches[0].key : undefined,
|
|
1159
|
-
requiresSelection: !matched,
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// ── Command: create store ────────────────────────────────────────────────
|
|
956
|
+
const projectSlug = project.slug ?? project.name;
|
|
1164
957
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const urlIdx = args.indexOf("--url");
|
|
1174
|
-
const urlValue = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
|
|
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
|
+
);
|
|
1175
964
|
|
|
1176
|
-
|
|
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;
|
|
1177
997
|
|
|
1178
|
-
|
|
1179
|
-
"create store",
|
|
1180
|
-
{ type: "Saleor Cloud store configuration", scope: "local .env" },
|
|
1181
|
-
"low",
|
|
1182
|
-
["credential handling"],
|
|
1183
|
-
true,
|
|
1184
|
-
["Writes NEXT_PUBLIC_SALEOR_API_URL to .env"],
|
|
1185
|
-
);
|
|
998
|
+
const values: Record<string, string> = { NEXT_PUBLIC_SALEOR_API_URL: domainUrl };
|
|
1186
999
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
+
}
|
|
1190
1009
|
|
|
1191
|
-
|
|
1192
|
-
const jollyManaged = ["NEXT_PUBLIC_SALEOR_API_URL", "JOLLY_STRIPE_PUBLISHABLE_KEY", "JOLLY_STRIPE_SECRET_KEY", "JOLLY_SALEOR_CLOUD_TOKEN", "JOLLY_SALEOR_APP_TOKEN", "JOLLY_SALEOR_ORGANIZATION"];
|
|
1193
|
-
const hasUnrelatedKeys = Object.keys(existing).some((k) => !jollyManaged.includes(k));
|
|
1010
|
+
writeEnvValues(projectDir(), values);
|
|
1194
1011
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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.",
|
|
1207
1035
|
},
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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));
|
|
1214
1055
|
}
|
|
1056
|
+
}
|
|
1215
1057
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1058
|
+
function cloudErrorEnvelope(command: string, err: unknown, riskContext: RiskContext): Envelope {
|
|
1059
|
+
const code = err instanceof CloudApiError ? err.code : "CLOUD_API_ERROR";
|
|
1060
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1061
|
+
return errorEnvelope(
|
|
1062
|
+
command,
|
|
1063
|
+
"The Cloud API request failed. Nothing was created.",
|
|
1064
|
+
[
|
|
1065
|
+
{
|
|
1066
|
+
code,
|
|
1067
|
+
message,
|
|
1068
|
+
remediation:
|
|
1069
|
+
code === "ENVIRONMENT_LIMIT_REACHED"
|
|
1070
|
+
? "Delete an unused environment or upgrade the plan, then re-run."
|
|
1071
|
+
: code === "DOMAIN_LABEL_TAKEN"
|
|
1072
|
+
? "Choose a different domain label with --domain-label <label>."
|
|
1073
|
+
: "Confirm the Cloud token and that the Cloud API is reachable.",
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
{ data: { riskContext } },
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1226
1079
|
|
|
1227
|
-
|
|
1228
|
-
errorExit(
|
|
1229
|
-
buildEnvelope("create store", {
|
|
1230
|
-
status: "error",
|
|
1231
|
-
summary: "No URL provided. Usage: jolly create store --url <saleor-url>",
|
|
1232
|
-
data: {},
|
|
1233
|
-
errors: [{ code: "MISSING_URL", message: "A Saleor URL is required." }],
|
|
1234
|
-
}),
|
|
1235
|
-
);
|
|
1236
|
-
}
|
|
1080
|
+
// ─── create app-token (feature 024) ───────────────────────────────────────
|
|
1237
1081
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
errorExit(
|
|
1253
|
-
buildEnvelope("create store", {
|
|
1254
|
-
status: "error",
|
|
1255
|
-
summary: "Endpoint validation failed. Nothing was written to .env.",
|
|
1256
|
-
data: { url, envUpdated: false },
|
|
1257
|
-
checks: [
|
|
1258
|
-
{ id: "create-store-validate-endpoint", status: "fail" as CheckStatus, description: validation.message },
|
|
1259
|
-
],
|
|
1260
|
-
errors: [{
|
|
1261
|
-
code: validation.code,
|
|
1262
|
-
message: validation.message,
|
|
1263
|
-
remediation: "Verify the Saleor GraphQL endpoint URL (https://<store>.saleor.cloud/graphql/) and that the instance is reachable, then re-run jolly create store --url <url> --validate.",
|
|
1264
|
-
}],
|
|
1265
|
-
}),
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
extraChecks.push({ id: "create-store-validate-endpoint", status: "pass" as CheckStatus, description: "Live introspection-style GraphQL validation succeeded" });
|
|
1269
|
-
}
|
|
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
|
+
}
|
|
1270
1096
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
}
|
|
1097
|
+
async function commandCreateAppToken(args: ParsedArgs): Promise<Envelope> {
|
|
1098
|
+
const command = "create app-token";
|
|
1099
|
+
const token = process.env["JOLLY_SALEOR_CLOUD_TOKEN"];
|
|
1100
|
+
const values = loadEnvValues(projectDir());
|
|
1101
|
+
const instanceUrl =
|
|
1102
|
+
args.options["url"] ??
|
|
1103
|
+
values["NEXT_PUBLIC_SALEOR_API_URL"] ??
|
|
1104
|
+
process.env["NEXT_PUBLIC_SALEOR_API_URL"];
|
|
1105
|
+
|
|
1106
|
+
if (args.dryRun) {
|
|
1107
|
+
return envelope({
|
|
1108
|
+
command,
|
|
1109
|
+
status: "success",
|
|
1110
|
+
summary: "Previewed app token creation; no GraphQL mutation was sent.",
|
|
1111
|
+
data: {
|
|
1112
|
+
dryRun: true,
|
|
1113
|
+
instanceUrl: instanceUrl ?? null,
|
|
1114
|
+
riskContext: appTokenRiskContext(instanceUrl ?? "unresolved Saleor GraphQL endpoint"),
|
|
1115
|
+
},
|
|
1116
|
+
nextSteps: [
|
|
1117
|
+
{
|
|
1118
|
+
description: "Run the command without --dry-run to create and store the app token.",
|
|
1119
|
+
command: "jolly create app-token",
|
|
1120
|
+
},
|
|
1121
|
+
],
|
|
1122
|
+
});
|
|
1314
1123
|
}
|
|
1315
1124
|
|
|
1316
|
-
if (
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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") } },
|
|
1327
1137
|
);
|
|
1328
|
-
return;
|
|
1329
1138
|
}
|
|
1330
1139
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
database_population: "sample",
|
|
1341
|
-
service: "saleor",
|
|
1342
|
-
region: "us-east-1",
|
|
1343
|
-
};
|
|
1344
|
-
const taskId = "task-" + Math.random().toString(36).slice(2, 10);
|
|
1345
|
-
const taskPollUrl = `https://api.saleor.cloud/platform/api/service/task-status/${taskId}`;
|
|
1346
|
-
|
|
1347
|
-
// Collision detection
|
|
1348
|
-
const isCollision = args.includes("--collision") || url.includes("existing-shop");
|
|
1349
|
-
if (isCollision) {
|
|
1350
|
-
output(
|
|
1351
|
-
buildEnvelope("create store", {
|
|
1352
|
-
status: "warning",
|
|
1353
|
-
summary: "Domain label collision: 'existing-shop' is already taken. Suggesting an alternative.",
|
|
1354
|
-
data: {
|
|
1355
|
-
requestUrl,
|
|
1356
|
-
requestBody: { ...requestBody, domain_label: "existing-shop" },
|
|
1357
|
-
taskId,
|
|
1358
|
-
taskPollUrl,
|
|
1359
|
-
suggestedDomain: "existing-shop-2",
|
|
1360
|
-
retryAvailable: true,
|
|
1361
|
-
retried: true,
|
|
1362
|
-
envUpdated: false,
|
|
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>.",
|
|
1363
1149
|
},
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
],
|
|
1367
|
-
nextSteps: [
|
|
1368
|
-
{ description: "Provide a new domain label to retry the request" },
|
|
1369
|
-
],
|
|
1370
|
-
}),
|
|
1150
|
+
],
|
|
1151
|
+
{ data: { riskContext: appTokenRiskContext("unresolved") } },
|
|
1371
1152
|
);
|
|
1372
|
-
return;
|
|
1373
1153
|
}
|
|
1374
1154
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
taskPollUrl,
|
|
1393
|
-
projectCreateUrl,
|
|
1394
|
-
projectBody,
|
|
1395
|
-
projectCreated: true,
|
|
1396
|
-
environmentCreated: true,
|
|
1397
|
-
url,
|
|
1398
|
-
envUpdated: true,
|
|
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.",
|
|
1399
1172
|
},
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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) } },
|
|
1408
1189
|
);
|
|
1409
|
-
return;
|
|
1410
1190
|
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ─── create stripe (feature 005) ──────────────────────────────────────────
|
|
1411
1194
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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,
|
|
1419
1204
|
};
|
|
1205
|
+
}
|
|
1420
1206
|
|
|
1421
|
-
|
|
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"];
|
|
1422
1211
|
|
|
1423
|
-
if (
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
}),
|
|
1212
|
+
if (!publishable || !secret) {
|
|
1213
|
+
return errorEnvelope(
|
|
1214
|
+
command,
|
|
1215
|
+
"Both --publishable-key and --secret-key are required.",
|
|
1216
|
+
[
|
|
1217
|
+
{
|
|
1218
|
+
code: "MISSING_STRIPE_KEYS",
|
|
1219
|
+
message: "create stripe needs --publishable-key <pk_test_...> and --secret-key <sk_test_...>.",
|
|
1220
|
+
remediation: "Copy both test-mode keys from the Stripe Dashboard and pass them as flags.",
|
|
1221
|
+
},
|
|
1222
|
+
],
|
|
1223
|
+
{ data: { riskContext: stripeRiskContext() } },
|
|
1439
1224
|
);
|
|
1440
|
-
return;
|
|
1441
1225
|
}
|
|
1442
1226
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1227
|
+
if (args.dryRun) {
|
|
1228
|
+
return envelope({
|
|
1229
|
+
command,
|
|
1445
1230
|
status: "success",
|
|
1446
|
-
summary: "
|
|
1447
|
-
data: {
|
|
1448
|
-
checks: [
|
|
1449
|
-
...extraChecks,
|
|
1450
|
-
{ id: "create-store-url-written", status: "pass" as CheckStatus, description: "NEXT_PUBLIC_SALEOR_API_URL written to .env" },
|
|
1451
|
-
],
|
|
1231
|
+
summary: "Previewed Stripe key storage; nothing was written.",
|
|
1232
|
+
data: { dryRun: true, riskContext: stripeRiskContext() },
|
|
1452
1233
|
nextSteps: [
|
|
1453
|
-
{
|
|
1234
|
+
{
|
|
1235
|
+
description: "Run the command without --dry-run to write the Stripe keys to .env.",
|
|
1236
|
+
command: "jolly create stripe --publishable-key <pk> --secret-key <sk>",
|
|
1237
|
+
},
|
|
1454
1238
|
],
|
|
1455
|
-
})
|
|
1456
|
-
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
writeEnvValues(projectDir(), {
|
|
1243
|
+
JOLLY_STRIPE_PUBLISHABLE_KEY: publishable,
|
|
1244
|
+
JOLLY_STRIPE_SECRET_KEY: secret,
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
return envelope({
|
|
1248
|
+
command,
|
|
1249
|
+
status: "success",
|
|
1250
|
+
summary:
|
|
1251
|
+
"Stored Stripe test-mode keys as JOLLY_STRIPE_PUBLISHABLE_KEY and JOLLY_STRIPE_SECRET_KEY.",
|
|
1252
|
+
data: { stored: true, riskContext: stripeRiskContext() },
|
|
1253
|
+
checks: [
|
|
1254
|
+
{ id: "stripe-keys-stored", status: "pass", description: "Stripe test-mode keys written to .env." },
|
|
1255
|
+
],
|
|
1256
|
+
nextSteps: [
|
|
1257
|
+
{
|
|
1258
|
+
description:
|
|
1259
|
+
"Configure Saleor's Stripe integration via @saleor/configurator, guided by the Jolly skill.",
|
|
1260
|
+
command: "jolly doctor stripe",
|
|
1261
|
+
},
|
|
1262
|
+
],
|
|
1263
|
+
});
|
|
1457
1264
|
}
|
|
1458
1265
|
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
function cmdCreateStripe(): void {
|
|
1462
|
-
const pkIdx = args.indexOf("--publishable-key");
|
|
1463
|
-
const skIdx = args.indexOf("--secret-key");
|
|
1464
|
-
const pk = pkIdx >= 0 ? args[pkIdx + 1] : undefined;
|
|
1465
|
-
const sk = skIdx >= 0 ? args[skIdx + 1] : undefined;
|
|
1466
|
-
|
|
1467
|
-
const rc = riskContext(
|
|
1468
|
-
"create stripe",
|
|
1469
|
-
{ type: "Stripe test-mode credentials", scope: "local .env" },
|
|
1470
|
-
"medium",
|
|
1471
|
-
["payment setup", "credential handling"],
|
|
1472
|
-
true,
|
|
1473
|
-
["Writes JOLLY_STRIPE_PUBLISHABLE_KEY and JOLLY_STRIPE_SECRET_KEY to .env"],
|
|
1474
|
-
);
|
|
1266
|
+
// ─── create dispatcher + help ─────────────────────────────────────────────
|
|
1475
1267
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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.",
|
|
1485
1281
|
},
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1282
|
+
{
|
|
1283
|
+
name: "app-token",
|
|
1284
|
+
description: "Acquire a Saleor app token via GraphQL and write it to .env.",
|
|
1285
|
+
},
|
|
1286
|
+
{ name: "stripe", description: "Write Stripe test-mode keys to .env." },
|
|
1287
|
+
],
|
|
1288
|
+
note: "Other setup work is run by your agent via the official CLIs, guided by the Jolly skill.",
|
|
1289
|
+
},
|
|
1290
|
+
nextSteps: [
|
|
1291
|
+
{
|
|
1292
|
+
description: "Run jolly create store --create-environment to provision a Saleor Cloud environment.",
|
|
1293
|
+
command: "jolly create store --create-environment",
|
|
1294
|
+
},
|
|
1295
|
+
],
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function commandCreate(args: ParsedArgs): Promise<Envelope> {
|
|
1300
|
+
const sub = args.positionals[1];
|
|
1301
|
+
if (!sub || args.help || sub === "help") {
|
|
1302
|
+
return commandCreateHelp();
|
|
1303
|
+
}
|
|
1304
|
+
switch (sub) {
|
|
1305
|
+
case "store":
|
|
1306
|
+
return commandCreateStore(args);
|
|
1307
|
+
case "app-token":
|
|
1308
|
+
return commandCreateAppToken(args);
|
|
1309
|
+
case "stripe":
|
|
1310
|
+
return commandCreateStripe(args);
|
|
1311
|
+
default:
|
|
1312
|
+
return errorEnvelope("create", `Unknown create subcommand "${sub}".`, [
|
|
1313
|
+
{
|
|
1314
|
+
code: "UNKNOWN_CREATE_SUBCOMMAND",
|
|
1315
|
+
message: `"${sub}" is not a create subcommand. Valid: ${CREATE_SUBCOMMANDS.join(", ")}.`,
|
|
1316
|
+
remediation: "Run `jolly create --help` to list available subcommands.",
|
|
1317
|
+
},
|
|
1318
|
+
]);
|
|
1492
1319
|
}
|
|
1320
|
+
}
|
|
1493
1321
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
1354
|
}
|
|
1355
|
+
const servers = (
|
|
1356
|
+
config["mcpServers"] && typeof config["mcpServers"] === "object"
|
|
1357
|
+
? (config["mcpServers"] as Record<string, unknown>)
|
|
1358
|
+
: {}
|
|
1359
|
+
) as Record<string, unknown>;
|
|
1360
|
+
// Merge: add our entry without removing user-authored servers.
|
|
1361
|
+
servers["saleor-graphql"] = jollyEntry;
|
|
1362
|
+
config["mcpServers"] = servers;
|
|
1363
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
1364
|
+
return { merged: true };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function mergeAgentsMd(): void {
|
|
1368
|
+
const path = join(projectDir(), "AGENTS.md");
|
|
1369
|
+
const begin = "<!-- jolly:begin -->";
|
|
1370
|
+
const end = "<!-- jolly:end -->";
|
|
1371
|
+
const section = `${begin}
|
|
1372
|
+
## Jolly
|
|
1373
|
+
|
|
1374
|
+
This project uses Jolly to set up a Saleor storefront. Run \`jolly start\` to
|
|
1375
|
+
bootstrap, then follow the Jolly skill to drive the official CLIs.
|
|
1376
|
+
${end}`;
|
|
1507
1377
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1378
|
+
let existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1379
|
+
if (existing.includes(begin) && existing.includes(end)) {
|
|
1380
|
+
existing = existing.replace(new RegExp(`${begin}[\\s\\S]*?${end}`), section);
|
|
1381
|
+
} else {
|
|
1382
|
+
existing =
|
|
1383
|
+
existing.length > 0
|
|
1384
|
+
? `${existing.replace(/\n+$/, "")}\n\n${section}\n`
|
|
1385
|
+
: `${section}\n`;
|
|
1386
|
+
}
|
|
1387
|
+
writeFileSync(path, existing);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function commandInit(_args: ParsedArgs): Envelope {
|
|
1391
|
+
const command = "init";
|
|
1392
|
+
const checks: Check[] = [];
|
|
1393
|
+
const installFailures: string[] = [];
|
|
1394
|
+
|
|
1395
|
+
for (const skill of DEFAULT_SKILLS) {
|
|
1396
|
+
const already = skillInstalledOnDisk(skill);
|
|
1397
|
+
if (!already) {
|
|
1398
|
+
installSkill(skill);
|
|
1399
|
+
}
|
|
1400
|
+
// Verify on disk — never unconditionally claim success.
|
|
1401
|
+
const present = skillInstalledOnDisk(skill);
|
|
1402
|
+
checks.push({
|
|
1403
|
+
id: `skill-${skill.id}`,
|
|
1404
|
+
status: present ? "pass" : "fail",
|
|
1405
|
+
description: present
|
|
1406
|
+
? `${skill.id} present on disk${already ? " (already installed)" : ""}.`
|
|
1407
|
+
: `${skill.id} could not be verified on disk after npx skills add.`,
|
|
1408
|
+
});
|
|
1409
|
+
if (!present) installFailures.push(skill.id);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Merge .mcp.json (local mcp-graphql against the customer endpoint).
|
|
1413
|
+
const mcp = mergeMcpJson();
|
|
1414
|
+
checks.push({
|
|
1415
|
+
id: "mcp-config",
|
|
1416
|
+
status: mcp.merged ? "pass" : "warning",
|
|
1417
|
+
description: mcp.merged
|
|
1418
|
+
? "Merged saleor-graphql entry into .mcp.json."
|
|
1419
|
+
: mcp.warning ?? "Could not merge .mcp.json.",
|
|
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
|
-
|
|
1487
|
+
const wants = (g: string) => !group || group === g;
|
|
1488
|
+
|
|
1489
|
+
// CLI availability (always reportable, read-only).
|
|
1490
|
+
if (!group) {
|
|
1491
|
+
checks.push({
|
|
1492
|
+
id: "cli-available",
|
|
1493
|
+
status: "pass",
|
|
1494
|
+
description: `Jolly CLI is available (Node ${process.versions.node}).`,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (wants("skills")) {
|
|
1499
|
+
for (const skill of DEFAULT_SKILLS) {
|
|
1500
|
+
const present = skillInstalledOnDisk(skill);
|
|
1501
|
+
checks.push({
|
|
1502
|
+
id: `skill-${skill.id}`,
|
|
1503
|
+
status: present ? "pass" : "fail",
|
|
1504
|
+
description: present ? `${skill.id} present.` : `${skill.id} not installed.`,
|
|
1505
|
+
command: present ? undefined : "jolly init",
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1568
1508
|
}
|
|
1569
1509
|
|
|
1570
|
-
if (
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
checks: [
|
|
1577
|
-
{ id: "deployment-vercel", status: "skipped" as CheckStatus, description: "Vercel config (check requires credentials)" },
|
|
1578
|
-
{ id: "deployment-stripe", status: "skipped" as CheckStatus, description: "Stripe test mode (check requires credentials)" },
|
|
1579
|
-
],
|
|
1580
|
-
}),
|
|
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"],
|
|
1581
1516
|
);
|
|
1582
|
-
|
|
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
|
+
});
|
|
1583
1542
|
}
|
|
1584
1543
|
|
|
1585
|
-
if (
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
{ id: "stripe-secret-key", status: (hasKeys ? "pass" : "fail") as CheckStatus, description: "JOLLY_STRIPE_SECRET_KEY" },
|
|
1599
|
-
],
|
|
1600
|
-
nextSteps: hasKeys
|
|
1601
|
-
? []
|
|
1602
|
-
: [{ description: "Run jolly create stripe --publishable-key <pk> --secret-key <sk>" }],
|
|
1603
|
-
}),
|
|
1604
|
-
);
|
|
1605
|
-
return;
|
|
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
|
+
});
|
|
1606
1557
|
}
|
|
1607
1558
|
|
|
1608
|
-
if (
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
}),
|
|
1559
|
+
if (wants("deployment")) {
|
|
1560
|
+
// Deployment is agent-run via the Vercel CLI; Jolly cannot verify it from
|
|
1561
|
+
// its own first-party-host code, so report skipped (honest, not fail).
|
|
1562
|
+
checks.push({
|
|
1563
|
+
id: "deployment-status",
|
|
1564
|
+
status: "skipped",
|
|
1565
|
+
description: "Deployment is run by your agent via the Vercel CLI; Jolly does not contact Vercel.",
|
|
1566
|
+
command: "npx vercel",
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (wants("stripe")) {
|
|
1571
|
+
const hasPub = Boolean(
|
|
1572
|
+
values["JOLLY_STRIPE_PUBLISHABLE_KEY"] ?? process.env["JOLLY_STRIPE_PUBLISHABLE_KEY"],
|
|
1573
|
+
);
|
|
1574
|
+
const hasSecret = Boolean(
|
|
1575
|
+
values["JOLLY_STRIPE_SECRET_KEY"] ?? process.env["JOLLY_STRIPE_SECRET_KEY"],
|
|
1626
1576
|
);
|
|
1627
|
-
|
|
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
|
+
});
|
|
1628
1584
|
}
|
|
1629
1585
|
|
|
1630
|
-
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1586
|
+
const hasFail = checks.some((c) => c.status === "fail");
|
|
1587
|
+
const hasWarn = checks.some((c) => c.status === "warning");
|
|
1588
|
+
const status: EnvelopeStatus = hasFail ? "error" : hasWarn ? "warning" : "success";
|
|
1589
|
+
|
|
1590
|
+
// Gather next steps from actionable checks.
|
|
1591
|
+
const nextSteps: NextStep[] = checks
|
|
1592
|
+
.filter((c) => (c.status === "fail" || c.status === "warning") && c.command)
|
|
1593
|
+
.map((c) => ({ description: c.description ?? `Address ${c.id}.`, command: c.command }));
|
|
1594
|
+
|
|
1595
|
+
return envelope({
|
|
1596
|
+
command: group ? `doctor ${group}` : "doctor",
|
|
1597
|
+
status,
|
|
1598
|
+
summary:
|
|
1599
|
+
status === "success"
|
|
1600
|
+
? "All performed checks passed."
|
|
1601
|
+
: status === "warning"
|
|
1602
|
+
? "Some checks need attention."
|
|
1603
|
+
: "Some checks failed; see next steps.",
|
|
1604
|
+
data: { group: group ?? "all" },
|
|
1605
|
+
checks,
|
|
1606
|
+
nextSteps,
|
|
1607
|
+
errors: hasFail
|
|
1608
|
+
? [
|
|
1609
|
+
{
|
|
1610
|
+
code: "DOCTOR_CHECKS_FAILED",
|
|
1611
|
+
message: "One or more diagnostics failed.",
|
|
1612
|
+
remediation: "Address the failing checks listed in nextSteps.",
|
|
1613
|
+
},
|
|
1614
|
+
]
|
|
1615
|
+
: [],
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// ─── skills (feature 006/001) ─────────────────────────────────────────────
|
|
1620
|
+
|
|
1621
|
+
function commandSkills(args: ParsedArgs): Envelope {
|
|
1622
|
+
const command = "skills";
|
|
1623
|
+
const sub = args.positionals[1];
|
|
1624
|
+
|
|
1625
|
+
if (sub === "install" || sub === "update") {
|
|
1626
|
+
const checks: Check[] = DEFAULT_SKILLS.map((skill) => {
|
|
1627
|
+
const already = skillInstalledOnDisk(skill);
|
|
1628
|
+
if (!already && sub === "install") installSkill(skill);
|
|
1629
|
+
const present = skillInstalledOnDisk(skill);
|
|
1630
|
+
return {
|
|
1631
|
+
id: `skill-${skill.id}`,
|
|
1632
|
+
status: present ? "pass" : "fail",
|
|
1633
|
+
description: present ? `${skill.id} present.` : `${skill.id} not verified on disk.`,
|
|
1634
|
+
};
|
|
1635
|
+
});
|
|
1636
|
+
const failed = checks.filter((c) => c.status === "fail").map((c) => c.id);
|
|
1637
|
+
return envelope({
|
|
1638
|
+
command: `skills ${sub}`,
|
|
1639
|
+
status: failed.length > 0 ? "warning" : "success",
|
|
1640
|
+
summary:
|
|
1641
|
+
failed.length > 0
|
|
1642
|
+
? `Some skills not verified: ${failed.join(", ")}.`
|
|
1643
|
+
: `Skills ${sub === "install" ? "installed" : "checked"}.`,
|
|
1644
|
+
data: { skills: DEFAULT_SKILLS.map((s) => s.id) },
|
|
1645
|
+
checks,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1642
1648
|
|
|
1643
|
-
|
|
1644
|
-
const
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
+
};
|
|
1648
1657
|
});
|
|
1649
1658
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
:
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
+
});
|
|
1664
1674
|
}
|
|
1665
1675
|
|
|
1666
|
-
//
|
|
1676
|
+
// ─── upgrade (feature 017) ────────────────────────────────────────────────
|
|
1667
1677
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
+
function commandUpgrade(_args: ParsedArgs): Envelope {
|
|
1679
|
+
const command = "upgrade";
|
|
1680
|
+
const checks: Check[] = DEFAULT_SKILLS.map((skill) => {
|
|
1681
|
+
const present = skillInstalledOnDisk(skill);
|
|
1682
|
+
return {
|
|
1683
|
+
id: `skill-${skill.id}`,
|
|
1684
|
+
status: present ? "pass" : "skipped",
|
|
1685
|
+
description: present
|
|
1686
|
+
? `${skill.id} is managed; checked for updates.`
|
|
1687
|
+
: `${skill.id} not installed; skipped.`,
|
|
1688
|
+
};
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// Detect a cloned Paper storefront for plan-only baseline guidance.
|
|
1692
|
+
const paperPresent = existsSync(join(projectDir(), "paper-version.json"));
|
|
1693
|
+
checks.push({
|
|
1694
|
+
id: "paper-baseline",
|
|
1695
|
+
status: paperPresent ? "unknown" : "skipped",
|
|
1696
|
+
description: paperPresent
|
|
1697
|
+
? "Paper storefront detected; Jolly plans Paper migrations but does not auto-apply them in v1."
|
|
1698
|
+
: "No Paper storefront detected; nothing to plan.",
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
return envelope({
|
|
1702
|
+
command,
|
|
1703
|
+
status: "success",
|
|
1704
|
+
summary: "Checked Jolly-managed skills and guidance for updates; Paper changes are plan-only.",
|
|
1705
|
+
data: {
|
|
1706
|
+
skillsChecked: DEFAULT_SKILLS.map((s) => s.id),
|
|
1707
|
+
paperBaselineDetected: paperPresent,
|
|
1708
|
+
paperAutoApply: false,
|
|
1709
|
+
},
|
|
1710
|
+
checks,
|
|
1711
|
+
nextSteps: paperPresent
|
|
1712
|
+
? [{ description: "Review the Paper upgrade plan before applying any migration manually." }]
|
|
1713
|
+
: [],
|
|
1714
|
+
});
|
|
1678
1715
|
}
|
|
1679
1716
|
|
|
1680
|
-
|
|
1717
|
+
// ─── start (features 001/006) ─────────────────────────────────────────────
|
|
1718
|
+
|
|
1719
|
+
interface PlanStage {
|
|
1681
1720
|
stage: string;
|
|
1682
|
-
|
|
1683
|
-
|
|
1721
|
+
effects: {
|
|
1722
|
+
directoriesCreated: string[];
|
|
1723
|
+
filesWritten: string[];
|
|
1724
|
+
networkHostsContacted: string[];
|
|
1725
|
+
repositoriesCloned: string[];
|
|
1726
|
+
};
|
|
1684
1727
|
riskContext?: RiskContext;
|
|
1685
1728
|
}
|
|
1686
1729
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
* Emits exactly what `start` would do — directories created, files
|
|
1690
|
-
* written, network hosts contacted, repositories cloned — with a feature
|
|
1691
|
-
* 021 riskContext on every side-effecting stage. Touches nothing: no file
|
|
1692
|
-
* reads beyond the .env already loaded, no writes, no network calls.
|
|
1693
|
-
*/
|
|
1694
|
-
function cmdStartDryRun(): void {
|
|
1695
|
-
const effects = (partial: Partial<StageEffects>): StageEffects => ({
|
|
1696
|
-
directoriesCreated: [],
|
|
1697
|
-
filesWritten: [],
|
|
1698
|
-
networkHostsContacted: [],
|
|
1699
|
-
repositoriesCloned: [],
|
|
1700
|
-
...partial,
|
|
1701
|
-
});
|
|
1702
|
-
|
|
1703
|
-
const plan: PlanEntry[] = [
|
|
1730
|
+
function startPlan(): PlanStage[] {
|
|
1731
|
+
return [
|
|
1704
1732
|
{
|
|
1705
1733
|
stage: "init",
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
"
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1734
|
+
effects: {
|
|
1735
|
+
directoriesCreated: [".claude/skills"],
|
|
1736
|
+
filesWritten: [".mcp.json", "AGENTS.md"],
|
|
1737
|
+
networkHostsContacted: ["github.com"],
|
|
1738
|
+
repositoriesCloned: [],
|
|
1739
|
+
},
|
|
1740
|
+
riskContext: {
|
|
1741
|
+
action: "init",
|
|
1742
|
+
target: "local project (skills, .mcp.json, AGENTS.md)",
|
|
1743
|
+
riskLevel: "low",
|
|
1744
|
+
categories: [],
|
|
1745
|
+
reversible: true,
|
|
1746
|
+
sideEffects: ["Installs skills, writes .mcp.json and AGENTS.md"],
|
|
1747
|
+
dryRunAvailable: true,
|
|
1748
|
+
},
|
|
1749
|
+
},
|
|
1750
|
+
{
|
|
1751
|
+
stage: "auth",
|
|
1752
|
+
effects: {
|
|
1753
|
+
directoriesCreated: [],
|
|
1754
|
+
filesWritten: [".env"],
|
|
1755
|
+
networkHostsContacted: ["cloud.saleor.io", "auth.saleor.io"],
|
|
1756
|
+
repositoriesCloned: [],
|
|
1757
|
+
},
|
|
1758
|
+
riskContext: {
|
|
1759
|
+
action: "login",
|
|
1760
|
+
target: cloudApiBase(),
|
|
1761
|
+
riskLevel: "medium",
|
|
1762
|
+
categories: ["credential handling"],
|
|
1763
|
+
reversible: true,
|
|
1764
|
+
sideEffects: ["Acquires and stores a Saleor Cloud token in .env"],
|
|
1765
|
+
dryRunAvailable: true,
|
|
1766
|
+
},
|
|
1723
1767
|
},
|
|
1724
1768
|
{
|
|
1725
1769
|
stage: "store",
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
networkHostsContacted: ["cloud.saleor.io"],
|
|
1770
|
+
effects: {
|
|
1771
|
+
directoriesCreated: [],
|
|
1729
1772
|
filesWritten: [".env"],
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
["billing", "credential handling"],
|
|
1736
|
-
true,
|
|
1737
|
-
[
|
|
1738
|
-
"Creates a Saleor Cloud environment (consumes a sandbox slot)",
|
|
1739
|
-
"Writes NEXT_PUBLIC_SALEOR_API_URL and JOLLY_SALEOR_APP_TOKEN to .env",
|
|
1740
|
-
],
|
|
1773
|
+
networkHostsContacted: ["cloud.saleor.io"],
|
|
1774
|
+
repositoriesCloned: [],
|
|
1775
|
+
},
|
|
1776
|
+
riskContext: createStoreRiskContext(
|
|
1777
|
+
`${cloudApiBase()}/organizations/{organization}/environments/`,
|
|
1741
1778
|
),
|
|
1742
1779
|
},
|
|
1743
1780
|
{
|
|
1744
1781
|
stage: "storefront",
|
|
1745
|
-
|
|
1746
|
-
effects: effects({
|
|
1782
|
+
effects: {
|
|
1747
1783
|
directoriesCreated: ["storefront"],
|
|
1784
|
+
filesWritten: [],
|
|
1748
1785
|
networkHostsContacted: ["github.com"],
|
|
1749
|
-
repositoriesCloned: ["
|
|
1750
|
-
}
|
|
1751
|
-
riskContext:
|
|
1752
|
-
"
|
|
1753
|
-
|
|
1754
|
-
"low",
|
|
1755
|
-
[],
|
|
1756
|
-
true,
|
|
1757
|
-
["Clones
|
|
1758
|
-
|
|
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
|
+
},
|
|
1759
1797
|
},
|
|
1760
1798
|
{
|
|
1761
|
-
stage: "
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
{ type: "Vercel project", provider: "vercel" },
|
|
1769
|
-
"medium",
|
|
1770
|
-
["live deployment"],
|
|
1771
|
-
true,
|
|
1772
|
-
["Creates a Vercel project and triggers a deployment"],
|
|
1773
|
-
),
|
|
1799
|
+
stage: "deploy",
|
|
1800
|
+
effects: {
|
|
1801
|
+
directoriesCreated: [],
|
|
1802
|
+
filesWritten: [],
|
|
1803
|
+
networkHostsContacted: [],
|
|
1804
|
+
repositoriesCloned: [],
|
|
1805
|
+
},
|
|
1774
1806
|
},
|
|
1807
|
+
];
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function startPlaybook(): NextStep[] {
|
|
1811
|
+
return [
|
|
1775
1812
|
{
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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.",
|
|
1790
1828
|
},
|
|
1791
1829
|
{
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
effects: effects({}),
|
|
1830
|
+
description: "7. Deploy with the Vercel CLI under your own vercel login session.",
|
|
1831
|
+
command: "npx vercel",
|
|
1795
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" },
|
|
1796
1838
|
];
|
|
1797
|
-
|
|
1798
|
-
output(
|
|
1799
|
-
buildEnvelope("start", {
|
|
1800
|
-
status: "success",
|
|
1801
|
-
summary:
|
|
1802
|
-
"Dry-run: previewed the jolly start plan. Nothing was created, written, or contacted.",
|
|
1803
|
-
data: { dryRun: true, plan },
|
|
1804
|
-
checks: [
|
|
1805
|
-
{
|
|
1806
|
-
id: "start-dry-run",
|
|
1807
|
-
status: "pass" as CheckStatus,
|
|
1808
|
-
description: "Preview only — no files created or modified, no network calls",
|
|
1809
|
-
},
|
|
1810
|
-
],
|
|
1811
|
-
nextSteps: [
|
|
1812
|
-
{ description: "Run jolly start to execute this plan" },
|
|
1813
|
-
],
|
|
1814
|
-
}),
|
|
1815
|
-
);
|
|
1816
1839
|
}
|
|
1817
1840
|
|
|
1818
|
-
function
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
else if (stage.name === "store" && hasUrl) status = "pass" as CheckStatus;
|
|
1843
|
-
else status = "skipped" as CheckStatus;
|
|
1844
|
-
return { ...stage, status };
|
|
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
|
+
],
|
|
1845
1865
|
});
|
|
1846
|
-
|
|
1847
|
-
output(
|
|
1848
|
-
buildEnvelope("start", {
|
|
1849
|
-
status: "success",
|
|
1850
|
-
summary: `Setup orchestration: ${stageStatuses.filter((s) => s.status === "pass").length}/${stages.length} stages complete.`,
|
|
1851
|
-
data: { stages: stageStatuses },
|
|
1852
|
-
checks: stageStatuses.map((s) => ({
|
|
1853
|
-
id: `stage-${s.name}`,
|
|
1854
|
-
status: s.status,
|
|
1855
|
-
description: s.description,
|
|
1856
|
-
})),
|
|
1857
|
-
nextSteps: stageStatuses
|
|
1858
|
-
.filter((s) => s.status !== "pass")
|
|
1859
|
-
.map((s) => ({ description: `Complete stage: ${s.description}` })),
|
|
1860
|
-
}),
|
|
1861
|
-
);
|
|
1862
1866
|
}
|
|
1863
1867
|
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
function cmdSkills(sub: string): void {
|
|
1867
|
-
const jollyDir = join(cwd, ".jolly");
|
|
1868
|
-
if (!existsSync(jollyDir)) {
|
|
1869
|
-
mkdirSync(jollyDir, { recursive: true });
|
|
1870
|
-
}
|
|
1868
|
+
function commandStart(args: ParsedArgs): Envelope {
|
|
1869
|
+
if (args.dryRun) return commandStartDryRun();
|
|
1871
1870
|
|
|
1872
|
-
|
|
1873
|
-
output(
|
|
1874
|
-
buildEnvelope(`skills ${sub}`, {
|
|
1875
|
-
status: "success",
|
|
1876
|
-
summary: sub === "install"
|
|
1877
|
-
? "Saleor agent skills installed."
|
|
1878
|
-
: "Saleor agent skills updated.",
|
|
1879
|
-
data: {
|
|
1880
|
-
skills: [
|
|
1881
|
-
{ name: "saleor-storefront", status: sub === "update" ? "updated" : "installed" },
|
|
1882
|
-
{ name: "saleor-configurator", status: sub === "update" ? "updated" : "installed" },
|
|
1883
|
-
{ name: "storefront-builder", status: sub === "update" ? "updated" : "installed" },
|
|
1884
|
-
{ name: "saleor-core", status: sub === "update" ? "updated" : "installed" },
|
|
1885
|
-
{ name: "saleor-app", status: sub === "update" ? "updated" : "installed" },
|
|
1886
|
-
],
|
|
1887
|
-
},
|
|
1888
|
-
checks: [
|
|
1889
|
-
{ id: `skills-${sub}`, status: "pass" as CheckStatus, description: `Skills ${sub}ed` },
|
|
1890
|
-
],
|
|
1891
|
-
}),
|
|
1892
|
-
);
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1871
|
+
const command = "start";
|
|
1895
1872
|
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
+
});
|
|
1898
1882
|
|
|
1899
|
-
|
|
1883
|
+
const checks: Check[] = [
|
|
1884
|
+
...initEnv.checks.map((c) => ({ ...c, id: `init-${c.id}` })),
|
|
1885
|
+
...doctorEnv.checks.map((c) => ({ ...c, id: `doctor-${c.id}` })),
|
|
1886
|
+
];
|
|
1900
1887
|
|
|
1901
|
-
|
|
1902
|
-
|
|
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";
|
|
1903
1892
|
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
paper: { detected: false, migrationAvailable: false },
|
|
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,
|
|
1917
1905
|
},
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
}),
|
|
1926
|
-
);
|
|
1906
|
+
playbook: startPlaybook().map((s) => s.description),
|
|
1907
|
+
pendingStages: ["storefront", "recipe", "deploy"],
|
|
1908
|
+
},
|
|
1909
|
+
checks,
|
|
1910
|
+
nextSteps: startPlaybook(),
|
|
1911
|
+
errors: bootstrapFailed ? initEnv.errors : [],
|
|
1912
|
+
});
|
|
1927
1913
|
}
|
|
1928
1914
|
|
|
1929
|
-
//
|
|
1930
|
-
|
|
1931
|
-
function cmdCreateStorefront(): void {
|
|
1932
|
-
const rc = riskContext(
|
|
1933
|
-
"create storefront",
|
|
1934
|
-
{ type: "Paper storefront clone", scope: "local filesystem" },
|
|
1935
|
-
"low",
|
|
1936
|
-
[],
|
|
1937
|
-
true,
|
|
1938
|
-
["Clones saleor/storefront Paper template", "Initializes local Git repository"],
|
|
1939
|
-
);
|
|
1940
|
-
|
|
1941
|
-
if (FLAG_DRY_RUN) {
|
|
1942
|
-
output(
|
|
1943
|
-
buildEnvelope("create storefront", {
|
|
1944
|
-
status: "success",
|
|
1945
|
-
summary: "Dry-run: would clone Saleor Paper storefront into ./storefront",
|
|
1946
|
-
data: { dryRun: true, riskContext: rc, defaultDir: "storefront" },
|
|
1947
|
-
checks: [
|
|
1948
|
-
{ id: "create-storefront-dry-run", status: "pass" as CheckStatus, description: "Preview only" },
|
|
1949
|
-
],
|
|
1950
|
-
}),
|
|
1951
|
-
);
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1915
|
+
// ─── top-level help ───────────────────────────────────────────────────────
|
|
1954
1916
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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",
|
|
1965
1936
|
],
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
function cmdCreateAppToken(): void {
|
|
1973
|
-
const appIdIdx = args.indexOf("--app-id");
|
|
1974
|
-
const appId = appIdIdx >= 0 ? args[appIdIdx + 1] : undefined;
|
|
1975
|
-
const instanceUrl = args.indexOf("--instance") >= 0 ? args[args.indexOf("--instance") + 1] : undefined;
|
|
1976
|
-
const existing = loadEnvValues(cwd);
|
|
1977
|
-
const graphqlUrl = instanceUrl || existing["NEXT_PUBLIC_SALEOR_API_URL"] || "https://test-shop.saleor.cloud/graphql/";
|
|
1978
|
-
|
|
1979
|
-
const rc = riskContext(
|
|
1980
|
-
"create app-token",
|
|
1981
|
-
{ type: "Saleor GraphQL instance", url: graphqlUrl },
|
|
1982
|
-
"medium",
|
|
1983
|
-
["credential handling"],
|
|
1984
|
-
false,
|
|
1985
|
-
["Creates an app token with all available permissions", "Token grants GraphQL API access to the Saleor instance"],
|
|
1986
|
-
);
|
|
1987
|
-
|
|
1988
|
-
// ── Dry-run ─────────────────────────────────────────────────────────
|
|
1989
|
-
if (FLAG_DRY_RUN) {
|
|
1990
|
-
output(
|
|
1991
|
-
buildEnvelope("create app-token", {
|
|
1992
|
-
status: "success",
|
|
1993
|
-
summary: "Dry-run: would create an app token on the Saleor instance.",
|
|
1994
|
-
data: {
|
|
1995
|
-
dryRun: true,
|
|
1996
|
-
riskContext: rc,
|
|
1997
|
-
mutationsSent: 0,
|
|
1998
|
-
targetUrl: graphqlUrl,
|
|
1999
|
-
envUpdated: false,
|
|
2000
|
-
},
|
|
2001
|
-
checks: [
|
|
2002
|
-
{ id: "create-app-token-dry-run", status: "pass" as CheckStatus, description: "Preview only — no GraphQL mutations sent" },
|
|
2003
|
-
],
|
|
2004
|
-
nextSteps: [
|
|
2005
|
-
{ description: "Run jolly create app-token (without --dry-run) to create the token" },
|
|
2006
|
-
],
|
|
2007
|
-
}),
|
|
2008
|
-
);
|
|
2009
|
-
return;
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
// ── List apps (no --app-id) ─────────────────────────────────────────
|
|
2013
|
-
if (!appId) {
|
|
2014
|
-
// Simulate GetApps query result
|
|
2015
|
-
const graphqlQuery = `query GetApps { apps(first: 100) { edges { node { id name } } } }`;
|
|
2016
|
-
const apps = [
|
|
2017
|
-
{ id: "QXBybzpjbGktYXBwLWlk", name: "Saleor CLI App" },
|
|
2018
|
-
{ id: "QXBybzptY21jLWFwcC1pZA==", name: "Saleor CMS" },
|
|
2019
|
-
];
|
|
2020
|
-
|
|
2021
|
-
// If we're simulating no apps (test mode)
|
|
2022
|
-
if (appId === "none" || args.includes("--no-apps")) {
|
|
2023
|
-
output(
|
|
2024
|
-
buildEnvelope("create app-token", {
|
|
2025
|
-
status: "warning",
|
|
2026
|
-
summary: "No apps available on this Saleor instance. Create an app via the Dashboard first.",
|
|
2027
|
-
data: {
|
|
2028
|
-
graphqlQuery,
|
|
2029
|
-
instanceUrl: graphqlUrl,
|
|
2030
|
-
authMethod: "Bearer",
|
|
2031
|
-
apps: [],
|
|
2032
|
-
riskContext: rc,
|
|
2033
|
-
},
|
|
2034
|
-
checks: [
|
|
2035
|
-
{ id: "create-app-token-apps", status: "fail" as CheckStatus, description: "No apps found" },
|
|
2036
|
-
],
|
|
2037
|
-
errors: [{
|
|
2038
|
-
code: "NO_APPS_AVAILABLE",
|
|
2039
|
-
message: "No Saleor apps are installed on this instance. Create an app via the Saleor Dashboard first.",
|
|
2040
|
-
remediation: "Create an app in the Saleor Dashboard at your-instance.cloud.saleor.io/dashboard/",
|
|
2041
|
-
}],
|
|
2042
|
-
nextSteps: [
|
|
2043
|
-
{ description: "Create a Saleor app via the Dashboard, then re-run jolly create app-token" },
|
|
2044
|
-
],
|
|
2045
|
-
}),
|
|
2046
|
-
);
|
|
2047
|
-
return;
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
output(
|
|
2051
|
-
buildEnvelope("create app-token", {
|
|
2052
|
-
status: "success",
|
|
2053
|
-
summary: `${apps.length} app(s) found on the Saleor instance. Select one by providing --app-id.`,
|
|
2054
|
-
data: {
|
|
2055
|
-
graphqlQuery,
|
|
2056
|
-
instanceUrl: graphqlUrl,
|
|
2057
|
-
authMethod: "Bearer",
|
|
2058
|
-
apps,
|
|
2059
|
-
requiresSelection: apps.length > 1,
|
|
2060
|
-
riskContext: rc,
|
|
2061
|
-
},
|
|
2062
|
-
checks: [
|
|
2063
|
-
{ id: "create-app-token-apps", status: "pass" as CheckStatus, description: `${apps.length} app(s) found` },
|
|
2064
|
-
],
|
|
2065
|
-
nextSteps: [
|
|
2066
|
-
{ description: "Run jolly create app-token --app-id <app-id> to create a token for a specific app" },
|
|
2067
|
-
],
|
|
2068
|
-
}),
|
|
2069
|
-
);
|
|
2070
|
-
return;
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// ── Create token for selected app ───────────────────────────────────
|
|
2074
|
-
const graphqlMutation = `mutation { appTokenCreate(input: { app: "${appId}" }) { authToken errors { message } } }`;
|
|
2075
|
-
const requestedPermissions = [
|
|
2076
|
-
"MANAGE_PRODUCTS", "MANAGE_ORDERS", "MANAGE_CHECKOUTS",
|
|
2077
|
-
"MANAGE_USERS", "MANAGE_APPS", "MANAGE_CHANNELS",
|
|
2078
|
-
"MANAGE_GIFT_CARD", "MANAGE_MENUS", "MANAGE_PAGES",
|
|
2079
|
-
"MANAGE_PLUGINS", "MANAGE_SETTINGS", "MANAGE_SHIPPING",
|
|
2080
|
-
"MANAGE_STAFF", "MANAGE_TAXES", "MANAGE_TRANSLATIONS",
|
|
2081
|
-
"MANAGE_WAREHOUSES", "HANDLE_PAYMENTS", "HANDLE_CHECKOUTS",
|
|
2082
|
-
];
|
|
2083
|
-
const authToken = "jolly-app-token-" + base64UrlEncode(new Uint8Array(16).buffer);
|
|
2084
|
-
|
|
2085
|
-
writeEnvValues(cwd, { "JOLLY_SALEOR_APP_TOKEN": authToken });
|
|
2086
|
-
|
|
2087
|
-
output(
|
|
2088
|
-
buildEnvelope("create app-token", {
|
|
2089
|
-
status: "success",
|
|
2090
|
-
summary: "App token created and written to .env as JOLLY_SALEOR_APP_TOKEN.",
|
|
2091
|
-
data: {
|
|
2092
|
-
graphqlMutation,
|
|
2093
|
-
instanceUrl: graphqlUrl,
|
|
2094
|
-
authMethod: "Bearer",
|
|
2095
|
-
selectedAppId: appId,
|
|
2096
|
-
requestedPermissions,
|
|
2097
|
-
authToken: "<redacted>",
|
|
2098
|
-
envUpdated: true,
|
|
2099
|
-
riskContext: rc,
|
|
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",
|
|
2100
1943
|
},
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
{ id: "create-app-token-written", status: "pass" as CheckStatus, description: "JOLLY_SALEOR_APP_TOKEN written to .env" },
|
|
2104
|
-
],
|
|
2105
|
-
nextSteps: [
|
|
2106
|
-
{ description: "Verify the token with jolly auth status" },
|
|
2107
|
-
{ description: "Run saleor/configurator introspect with JOLLY_SALEOR_APP_TOKEN to discover channels, catalog structure, menus, and configuration" },
|
|
2108
|
-
],
|
|
2109
|
-
}),
|
|
2110
|
-
);
|
|
1944
|
+
],
|
|
1945
|
+
});
|
|
2111
1946
|
}
|
|
2112
1947
|
|
|
2113
|
-
//
|
|
1948
|
+
// ─── dispatch ─────────────────────────────────────────────────────────────
|
|
2114
1949
|
|
|
2115
|
-
async function
|
|
2116
|
-
|
|
2117
|
-
cmdHelp();
|
|
2118
|
-
return;
|
|
2119
|
-
}
|
|
1950
|
+
async function dispatch(args: ParsedArgs): Promise<Envelope> {
|
|
1951
|
+
const cmd = args.positionals[0];
|
|
2120
1952
|
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
switch (subcommand) {
|
|
1953
|
+
switch (cmd) {
|
|
2124
1954
|
case undefined:
|
|
2125
|
-
case "
|
|
2126
|
-
|
|
2127
|
-
cmdHelp();
|
|
2128
|
-
break;
|
|
2129
|
-
|
|
2130
|
-
case "init":
|
|
2131
|
-
cmdInit();
|
|
2132
|
-
break;
|
|
2133
|
-
|
|
1955
|
+
case "help":
|
|
1956
|
+
return commandHelp();
|
|
2134
1957
|
case "login":
|
|
2135
|
-
|
|
2136
|
-
break;
|
|
2137
|
-
|
|
1958
|
+
return commandLogin(args);
|
|
2138
1959
|
case "logout":
|
|
2139
|
-
|
|
2140
|
-
break;
|
|
2141
|
-
|
|
1960
|
+
return commandLogout(args);
|
|
2142
1961
|
case "auth":
|
|
2143
|
-
if (
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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
|
+
]);
|
|
2150
1970
|
case "create":
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
} else if (createSub === "store") {
|
|
2155
|
-
await cmdCreateStore();
|
|
2156
|
-
} else if (createSub === "stripe") {
|
|
2157
|
-
cmdCreateStripe();
|
|
2158
|
-
} else if (createSub === "storefront") {
|
|
2159
|
-
cmdCreateStorefront();
|
|
2160
|
-
} else if (createSub === "recipe") {
|
|
2161
|
-
output(
|
|
2162
|
-
buildEnvelope("create recipe", {
|
|
2163
|
-
status: "success",
|
|
2164
|
-
summary: "Jolly starter recipe prepared.",
|
|
2165
|
-
data: { recipe: "jolly-starter", path: "storefront/recipes/jolly-starter.yml" },
|
|
2166
|
-
checks: [
|
|
2167
|
-
{ id: "create-recipe", status: "pass" as CheckStatus, description: "Recipe ready" },
|
|
2168
|
-
],
|
|
2169
|
-
}),
|
|
2170
|
-
);
|
|
2171
|
-
} else if (createSub === "app-token") {
|
|
2172
|
-
cmdCreateAppToken();
|
|
2173
|
-
} else if (createSub === "deployment" || createSub === "deploy") {
|
|
2174
|
-
output(
|
|
2175
|
-
buildEnvelope("create deployment", {
|
|
2176
|
-
status: "success",
|
|
2177
|
-
summary: "Vercel deployment configured.",
|
|
2178
|
-
data: { provider: "vercel" },
|
|
2179
|
-
checks: [
|
|
2180
|
-
{ id: "create-deployment", status: "pass" as CheckStatus, description: "Deployment ready" },
|
|
2181
|
-
],
|
|
2182
|
-
}),
|
|
2183
|
-
);
|
|
2184
|
-
} else {
|
|
2185
|
-
errorExit(
|
|
2186
|
-
buildEnvelope(`create ${createSub}`, {
|
|
2187
|
-
status: "error",
|
|
2188
|
-
summary: `Unknown create subcommand: ${createSub}`,
|
|
2189
|
-
errors: [{ code: "UNKNOWN_SUBCOMMAND", message: `"${createSub}" is not a recognized create subcommand. Run jolly create --help for available subcommands.` }],
|
|
2190
|
-
}),
|
|
2191
|
-
);
|
|
2192
|
-
}
|
|
2193
|
-
break;
|
|
2194
|
-
|
|
2195
|
-
case "deploy":
|
|
2196
|
-
output(
|
|
2197
|
-
buildEnvelope("deploy", {
|
|
2198
|
-
status: "success",
|
|
2199
|
-
summary: "Vercel deployment configured.",
|
|
2200
|
-
data: { provider: "vercel" },
|
|
2201
|
-
checks: [
|
|
2202
|
-
{ id: "deploy", status: "pass" as CheckStatus, description: "Deployment ready" },
|
|
2203
|
-
],
|
|
2204
|
-
}),
|
|
2205
|
-
);
|
|
2206
|
-
break;
|
|
2207
|
-
|
|
1971
|
+
return commandCreate(args);
|
|
1972
|
+
case "init":
|
|
1973
|
+
return commandInit(args);
|
|
2208
1974
|
case "start":
|
|
2209
|
-
|
|
2210
|
-
break;
|
|
2211
|
-
|
|
1975
|
+
return commandStart(args);
|
|
2212
1976
|
case "doctor":
|
|
2213
|
-
|
|
2214
|
-
if (FLAG_HELP || !doctorSub) {
|
|
2215
|
-
if (FLAG_HELP) {
|
|
2216
|
-
cmdHelp("doctor");
|
|
2217
|
-
} else {
|
|
2218
|
-
cmdDoctor();
|
|
2219
|
-
}
|
|
2220
|
-
} else {
|
|
2221
|
-
cmdDoctor(doctorSub);
|
|
2222
|
-
}
|
|
2223
|
-
break;
|
|
2224
|
-
|
|
2225
|
-
case "skills":
|
|
2226
|
-
const skillsSub = cleanArgs(args)[1];
|
|
2227
|
-
if (skillsSub === "install" || skillsSub === "update") {
|
|
2228
|
-
cmdSkills(skillsSub);
|
|
2229
|
-
} else {
|
|
2230
|
-
cmdHelp("skills");
|
|
2231
|
-
}
|
|
2232
|
-
break;
|
|
2233
|
-
|
|
1977
|
+
return commandDoctor(args);
|
|
2234
1978
|
case "upgrade":
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
1979
|
+
return commandUpgrade(args);
|
|
1980
|
+
case "skills":
|
|
1981
|
+
return commandSkills(args);
|
|
2238
1982
|
default:
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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
|
+
]);
|
|
2247
2006
|
}
|
|
2007
|
+
const exitCode = emit(env, args);
|
|
2008
|
+
process.exit(exitCode);
|
|
2248
2009
|
}
|
|
2249
2010
|
|
|
2250
|
-
main();
|
|
2011
|
+
void main();
|