@dk/jolly 0.2.0 → 0.3.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 +7 -5
- package/bin/jolly +9 -22
- package/{src/index.ts → dist/index.js} +782 -825
- package/package.json +14 -10
- package/src/lib/cloud-api.ts +0 -555
- package/src/lib/env-file.ts +0 -68
- package/src/lib/saleor-url.ts +0 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dk/jolly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Ahoy, agent. Go build a Saleor storefront.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
-
"
|
|
11
|
+
"dist/",
|
|
12
12
|
"assets/skills/",
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
@@ -19,19 +19,23 @@
|
|
|
19
19
|
"node": ">=23.0.0"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"test
|
|
28
|
-
"
|
|
22
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
23
|
+
"prepack": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
24
|
+
"prepublishOnly": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
25
|
+
"dev": "node --watch src/index.ts",
|
|
26
|
+
"start": "node src/index.ts",
|
|
27
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
28
|
+
"test:bdd": "cucumber-js",
|
|
29
|
+
"test:logic": "cucumber-js -p logic",
|
|
30
|
+
"test:sandbox": "cucumber-js -p sandbox",
|
|
31
|
+
"test:eval": "cucumber-js -p eval",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
29
33
|
},
|
|
30
34
|
"devDependencies": {
|
|
31
35
|
"@cucumber/cucumber": "^11.3.0",
|
|
32
36
|
"@earendil-works/pi-coding-agent": "^0.79.1",
|
|
33
|
-
"@types/bun": "^1.3.14",
|
|
34
37
|
"@types/node": "^22.10.0",
|
|
38
|
+
"esbuild": "^0.24.0",
|
|
35
39
|
"happy-dom": "^15.11.0",
|
|
36
40
|
"typescript": "^5.7.0"
|
|
37
41
|
}
|
package/src/lib/cloud-api.ts
DELETED
|
@@ -1,555 +0,0 @@
|
|
|
1
|
-
// Saleor Cloud API client (feature 012 — existing Saleor store connection).
|
|
2
|
-
//
|
|
3
|
-
// Pinned by the feature 012 Rule "Existing-store automation principles":
|
|
4
|
-
// - The Cloud API is at https://cloud.saleor.io/platform/api, optionally
|
|
5
|
-
// overridden by JOLLY_SALEOR_CLOUD_API_URL (feature 018 Rule); every Cloud
|
|
6
|
-
// API request honors the override.
|
|
7
|
-
// Authenticate with `Authorization: Token <token>`.
|
|
8
|
-
// - Organizations: GET /platform/api/organizations/ returns a list with slug
|
|
9
|
-
// and environments URL.
|
|
10
|
-
// - Projects: POST /platform/api/organizations/{slug}/projects/ with body
|
|
11
|
-
// { name, plan: "dev", region }.
|
|
12
|
-
// - Environments: POST /platform/api/organizations/{slug}/environments/ with
|
|
13
|
-
// body { name, project, domain_label, database_population: "sample",
|
|
14
|
-
// service, region: "us-east-1" }. Returns a task_id.
|
|
15
|
-
// - Task status: GET /platform/api/service/task-status/{task_id} until
|
|
16
|
-
// status is "SUCCEEDED".
|
|
17
|
-
// - The environment task result contains the domain URL
|
|
18
|
-
// (https://{domain_label}.saleor.cloud/graphql/).
|
|
19
|
-
//
|
|
20
|
-
// Pinned by the Rule "Environment creation against in-use organizations":
|
|
21
|
-
// - When the Cloud API rejects environment creation because the
|
|
22
|
-
// organization's sandbox environment limit is reached, surface the stable
|
|
23
|
-
// error code ENVIRONMENT_LIMIT_REACHED.
|
|
24
|
-
//
|
|
25
|
-
// App token acquisition follows the deprecated CLI's example flow (reference
|
|
26
|
-
// material only, feature 012): authenticate to the instance's GraphQL API
|
|
27
|
-
// with the Cloud token (Bearer), select an existing local app or create one,
|
|
28
|
-
// and create an app token via the Saleor GraphQL API.
|
|
29
|
-
|
|
30
|
-
const DEFAULT_CLOUD_API_BASE = "https://cloud.saleor.io/platform/api";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The Cloud API base URL for this request: the JOLLY_SALEOR_CLOUD_API_URL
|
|
34
|
-
* override when set (feature 018 Rule — pointing it elsewhere is the
|
|
35
|
-
* customer's explicit choice), otherwise the first-party default.
|
|
36
|
-
*/
|
|
37
|
-
export function cloudApiBase(): string {
|
|
38
|
-
const override = process.env["JOLLY_SALEOR_CLOUD_API_URL"];
|
|
39
|
-
if (override && override.trim().length > 0) {
|
|
40
|
-
return override.trim().replace(/\/+$/, "");
|
|
41
|
-
}
|
|
42
|
-
return DEFAULT_CLOUD_API_BASE;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const POLL_INTERVAL_MS = 5_000;
|
|
46
|
-
const POLL_TIMEOUT_MS = 480_000; // stay under the harness's CLI timeout
|
|
47
|
-
|
|
48
|
-
/** Error from the Cloud API with a stable, branchable code. */
|
|
49
|
-
export class CloudApiError extends Error {
|
|
50
|
-
readonly code: string;
|
|
51
|
-
readonly httpStatus?: number;
|
|
52
|
-
|
|
53
|
-
constructor(message: string, code: string, httpStatus?: number) {
|
|
54
|
-
super(message);
|
|
55
|
-
this.name = "CloudApiError";
|
|
56
|
-
this.code = code;
|
|
57
|
-
this.httpStatus = httpStatus;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function cloudFetch(
|
|
62
|
-
url: string,
|
|
63
|
-
token: string,
|
|
64
|
-
options: RequestInit = {},
|
|
65
|
-
): Promise<Response> {
|
|
66
|
-
return await fetch(url, {
|
|
67
|
-
...options,
|
|
68
|
-
headers: {
|
|
69
|
-
Authorization: `Token ${token}`,
|
|
70
|
-
"Content-Type": "application/json",
|
|
71
|
-
...((options.headers as Record<string, string>) ?? {}),
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Organizations ────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
export interface CloudOrganization {
|
|
79
|
-
slug: string;
|
|
80
|
-
name?: string;
|
|
81
|
-
[key: string]: unknown;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** GET /platform/api/organizations/ */
|
|
85
|
-
export async function listOrganizations(
|
|
86
|
-
token: string,
|
|
87
|
-
): Promise<CloudOrganization[]> {
|
|
88
|
-
const response = await cloudFetch(`${cloudApiBase()}/organizations/`, token);
|
|
89
|
-
if (!response.ok) {
|
|
90
|
-
throw new CloudApiError(
|
|
91
|
-
`Failed to list organizations: HTTP ${response.status} ${await response.text()}`,
|
|
92
|
-
"CLOUD_API_ERROR",
|
|
93
|
-
response.status,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
return (await response.json()) as CloudOrganization[];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ── Projects (create-or-reuse) ───────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
export interface CloudProject {
|
|
102
|
-
name: string;
|
|
103
|
-
slug?: string;
|
|
104
|
-
plan?: string;
|
|
105
|
-
region?: string;
|
|
106
|
-
[key: string]: unknown;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** GET /platform/api/organizations/{slug}/projects/ */
|
|
110
|
-
export async function listProjects(
|
|
111
|
-
token: string,
|
|
112
|
-
organizationSlug: string,
|
|
113
|
-
): Promise<CloudProject[]> {
|
|
114
|
-
const response = await cloudFetch(
|
|
115
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/projects/`,
|
|
116
|
-
token,
|
|
117
|
-
);
|
|
118
|
-
if (!response.ok) {
|
|
119
|
-
throw new CloudApiError(
|
|
120
|
-
`Failed to list projects: HTTP ${response.status} ${await response.text()}`,
|
|
121
|
-
"CLOUD_API_ERROR",
|
|
122
|
-
response.status,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
return (await response.json()) as CloudProject[];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** POST /platform/api/organizations/{slug}/projects/ with { name, plan, region }. */
|
|
129
|
-
export async function createProject(
|
|
130
|
-
token: string,
|
|
131
|
-
organizationSlug: string,
|
|
132
|
-
body: { name: string; plan: string; region: string },
|
|
133
|
-
): Promise<CloudProject> {
|
|
134
|
-
const response = await cloudFetch(
|
|
135
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/projects/`,
|
|
136
|
-
token,
|
|
137
|
-
{ method: "POST", body: JSON.stringify(body) },
|
|
138
|
-
);
|
|
139
|
-
if (!response.ok) {
|
|
140
|
-
throw new CloudApiError(
|
|
141
|
-
`Failed to create project: HTTP ${response.status} ${await response.text()}`,
|
|
142
|
-
"PROJECT_CREATE_FAILED",
|
|
143
|
-
response.status,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
return (await response.json()) as CloudProject;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ── Services (concrete service identifier for environment creation) ──────
|
|
150
|
-
|
|
151
|
-
export interface CloudService {
|
|
152
|
-
name: string;
|
|
153
|
-
region?: string;
|
|
154
|
-
service_type?: string;
|
|
155
|
-
[key: string]: unknown;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** GET /platform/api/organizations/{org}/projects/{project}/services/ */
|
|
159
|
-
export async function listProjectServices(
|
|
160
|
-
token: string,
|
|
161
|
-
organizationSlug: string,
|
|
162
|
-
projectSlug: string,
|
|
163
|
-
): Promise<CloudService[]> {
|
|
164
|
-
const response = await cloudFetch(
|
|
165
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/projects/${projectSlug}/services/`,
|
|
166
|
-
token,
|
|
167
|
-
);
|
|
168
|
-
if (!response.ok) return [];
|
|
169
|
-
return (await response.json()) as CloudService[];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Pick the service identifier for environment creation: prefer a sandbox
|
|
174
|
-
* service in the default region, then any sandbox service, then the first
|
|
175
|
-
* listed; fall back to the spec's "saleor" default when discovery yields
|
|
176
|
-
* nothing.
|
|
177
|
-
*/
|
|
178
|
-
export function pickService(
|
|
179
|
-
services: CloudService[],
|
|
180
|
-
region: string = "us-east-1",
|
|
181
|
-
): string {
|
|
182
|
-
const sandbox = services.filter(
|
|
183
|
-
(s) => String(s.service_type ?? "").toUpperCase() === "SANDBOX",
|
|
184
|
-
);
|
|
185
|
-
const inRegion = sandbox.find((s) => s.region === region);
|
|
186
|
-
const chosen = inRegion ?? sandbox[0] ?? services[0];
|
|
187
|
-
return chosen?.name ?? "saleor";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── Environments ─────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
export interface CloudEnvironment {
|
|
193
|
-
key?: string;
|
|
194
|
-
name?: string;
|
|
195
|
-
domain?: string;
|
|
196
|
-
domain_label?: string;
|
|
197
|
-
task_id?: string;
|
|
198
|
-
[key: string]: unknown;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* POST /platform/api/organizations/{slug}/environments/ — returns the
|
|
203
|
-
* environment (with task_id for async provisioning). A rejection caused by
|
|
204
|
-
* the organization's sandbox environment limit surfaces as a CloudApiError
|
|
205
|
-
* with the stable code ENVIRONMENT_LIMIT_REACHED (feature 012 Rule).
|
|
206
|
-
*/
|
|
207
|
-
export async function createEnvironment(
|
|
208
|
-
token: string,
|
|
209
|
-
organizationSlug: string,
|
|
210
|
-
body: {
|
|
211
|
-
name: string;
|
|
212
|
-
project: string;
|
|
213
|
-
domain_label: string;
|
|
214
|
-
database_population: string;
|
|
215
|
-
service: string;
|
|
216
|
-
region: string;
|
|
217
|
-
},
|
|
218
|
-
): Promise<CloudEnvironment> {
|
|
219
|
-
const response = await cloudFetch(
|
|
220
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/environments/`,
|
|
221
|
-
token,
|
|
222
|
-
{ method: "POST", body: JSON.stringify(body) },
|
|
223
|
-
);
|
|
224
|
-
if (!response.ok) {
|
|
225
|
-
const text = await response.text();
|
|
226
|
-
if (
|
|
227
|
-
response.status >= 400 &&
|
|
228
|
-
response.status < 500 &&
|
|
229
|
-
/domain/i.test(text) &&
|
|
230
|
-
/taken|exists|already|unique|in use|duplicate/i.test(text)
|
|
231
|
-
) {
|
|
232
|
-
throw new CloudApiError(
|
|
233
|
-
`The Cloud API rejected the environment creation: the domain label ` +
|
|
234
|
-
`"${body.domain_label}" is already taken (HTTP ${response.status}).`,
|
|
235
|
-
"DOMAIN_LABEL_TAKEN",
|
|
236
|
-
response.status,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
if (
|
|
240
|
-
response.status >= 400 &&
|
|
241
|
-
response.status < 500 &&
|
|
242
|
-
/limit|quota|exceed/i.test(text)
|
|
243
|
-
) {
|
|
244
|
-
throw new CloudApiError(
|
|
245
|
-
"The organization's sandbox environment limit is reached. " +
|
|
246
|
-
"Delete an unused environment or upgrade the plan, then re-run " +
|
|
247
|
-
"`jolly create store --create-environment`.",
|
|
248
|
-
"ENVIRONMENT_LIMIT_REACHED",
|
|
249
|
-
response.status,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
throw new CloudApiError(
|
|
253
|
-
`Failed to create environment: HTTP ${response.status} ${text}`,
|
|
254
|
-
"ENVIRONMENT_CREATE_FAILED",
|
|
255
|
-
response.status,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
return (await response.json()) as CloudEnvironment;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** GET /platform/api/organizations/{slug}/environments/ */
|
|
262
|
-
export async function listEnvironments(
|
|
263
|
-
token: string,
|
|
264
|
-
organizationSlug: string,
|
|
265
|
-
): Promise<CloudEnvironment[]> {
|
|
266
|
-
const response = await cloudFetch(
|
|
267
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/environments/`,
|
|
268
|
-
token,
|
|
269
|
-
);
|
|
270
|
-
if (!response.ok) return [];
|
|
271
|
-
return (await response.json()) as CloudEnvironment[];
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** GET /platform/api/organizations/{slug}/environments/{key}/ */
|
|
275
|
-
export async function getEnvironment(
|
|
276
|
-
token: string,
|
|
277
|
-
organizationSlug: string,
|
|
278
|
-
environmentKey: string,
|
|
279
|
-
): Promise<CloudEnvironment | undefined> {
|
|
280
|
-
const response = await cloudFetch(
|
|
281
|
-
`${cloudApiBase()}/organizations/${organizationSlug}/environments/${environmentKey}/`,
|
|
282
|
-
token,
|
|
283
|
-
);
|
|
284
|
-
if (!response.ok) return undefined;
|
|
285
|
-
return (await response.json()) as CloudEnvironment;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ── Task polling ─────────────────────────────────────────────────────────
|
|
289
|
-
|
|
290
|
-
export interface TaskStatus {
|
|
291
|
-
status?: string;
|
|
292
|
-
[key: string]: unknown;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** The poll URL for a task: GET /platform/api/service/task-status/{task_id}. */
|
|
296
|
-
export function taskStatusUrl(taskId: string): string {
|
|
297
|
-
return `${cloudApiBase()}/service/task-status/${taskId}/`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Poll GET /platform/api/service/task-status/{task_id} until status is
|
|
302
|
-
* "SUCCEEDED". Throws on FAILED or on timeout. Returns the final task body
|
|
303
|
-
* so the caller can extract the resulting domain from the task result.
|
|
304
|
-
*
|
|
305
|
-
* Verified against the live Cloud API: the task id is the full job name
|
|
306
|
-
* from the creation response, and the endpoint is anonymous — sending the
|
|
307
|
-
* Cloud `Authorization: Token` header makes the service try (and fail) to
|
|
308
|
-
* decode it as a JWT, returning 401 "Error decoding signature".
|
|
309
|
-
*/
|
|
310
|
-
export async function pollTaskStatus(
|
|
311
|
-
taskId: string,
|
|
312
|
-
timeoutMs: number = POLL_TIMEOUT_MS,
|
|
313
|
-
): Promise<TaskStatus> {
|
|
314
|
-
const deadline = Date.now() + timeoutMs;
|
|
315
|
-
const url = taskStatusUrl(taskId);
|
|
316
|
-
for (;;) {
|
|
317
|
-
const response = await fetch(url, {
|
|
318
|
-
headers: { "Content-Type": "application/json" },
|
|
319
|
-
});
|
|
320
|
-
if (!response.ok) {
|
|
321
|
-
throw new CloudApiError(
|
|
322
|
-
`Task status check failed: HTTP ${response.status} ${await response.text()}`,
|
|
323
|
-
"TASK_STATUS_FAILED",
|
|
324
|
-
response.status,
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
const task = (await response.json()) as TaskStatus;
|
|
328
|
-
const status = String(task.status ?? "").toUpperCase();
|
|
329
|
-
if (status === "SUCCEEDED") return task;
|
|
330
|
-
if (status === "FAILED" || status === "ERROR") {
|
|
331
|
-
throw new CloudApiError(
|
|
332
|
-
`Environment provisioning task ${taskId} failed: ${JSON.stringify(task)}`,
|
|
333
|
-
"TASK_FAILED",
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
if (Date.now() + POLL_INTERVAL_MS > deadline) {
|
|
337
|
-
throw new CloudApiError(
|
|
338
|
-
`Environment provisioning task ${taskId} did not reach SUCCEEDED within ${Math.round(timeoutMs / 1000)}s (last status: ${status || "unknown"})`,
|
|
339
|
-
"TASK_TIMEOUT",
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
await sleep(POLL_INTERVAL_MS);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Extract the resulting GraphQL domain URL
|
|
348
|
-
* (https://{domain_label}.saleor.cloud/graphql/) from the task result,
|
|
349
|
-
* falling back to the environment object when the task body does not carry
|
|
350
|
-
* it (the exact task-status shape is verified against the live API).
|
|
351
|
-
*/
|
|
352
|
-
export function extractDomainUrl(
|
|
353
|
-
task: TaskStatus | undefined,
|
|
354
|
-
environment: CloudEnvironment | undefined,
|
|
355
|
-
domainLabel: string,
|
|
356
|
-
): string {
|
|
357
|
-
const candidates: unknown[] = [];
|
|
358
|
-
if (task) {
|
|
359
|
-
const result = task.result as Record<string, unknown> | undefined;
|
|
360
|
-
candidates.push(result?.domain, task.domain, environment?.domain);
|
|
361
|
-
} else {
|
|
362
|
-
candidates.push(environment?.domain);
|
|
363
|
-
}
|
|
364
|
-
for (const candidate of candidates) {
|
|
365
|
-
if (typeof candidate === "string" && candidate.length > 0) {
|
|
366
|
-
const domain = candidate.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
367
|
-
return `https://${domain}/graphql/`;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return `https://${domainLabel}.saleor.cloud/graphql/`;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ── Instance GraphQL: app token acquisition ──────────────────────────────
|
|
374
|
-
|
|
375
|
-
async function graphqlFetch(
|
|
376
|
-
graphqlUrl: string,
|
|
377
|
-
token: string,
|
|
378
|
-
query: string,
|
|
379
|
-
variables?: Record<string, unknown>,
|
|
380
|
-
): Promise<Record<string, unknown>> {
|
|
381
|
-
const response = await fetch(graphqlUrl, {
|
|
382
|
-
method: "POST",
|
|
383
|
-
headers: {
|
|
384
|
-
Authorization: `Bearer ${token}`,
|
|
385
|
-
"Content-Type": "application/json",
|
|
386
|
-
},
|
|
387
|
-
body: JSON.stringify(variables ? { query, variables } : { query }),
|
|
388
|
-
});
|
|
389
|
-
if (!response.ok) {
|
|
390
|
-
throw new CloudApiError(
|
|
391
|
-
`GraphQL request to the Saleor instance failed: HTTP ${response.status}`,
|
|
392
|
-
"GRAPHQL_HTTP_ERROR",
|
|
393
|
-
response.status,
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
const body = (await response.json()) as Record<string, unknown>;
|
|
397
|
-
if (body.errors) {
|
|
398
|
-
throw new CloudApiError(
|
|
399
|
-
`GraphQL errors: ${JSON.stringify(body.errors)}`,
|
|
400
|
-
"GRAPHQL_ERROR",
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
return (body.data ?? {}) as Record<string, unknown>;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** query GetApps { apps(first: 100) { edges { node { id name } } } } */
|
|
407
|
-
export async function queryGetApps(
|
|
408
|
-
graphqlUrl: string,
|
|
409
|
-
token: string,
|
|
410
|
-
): Promise<Array<{ id: string; name: string }>> {
|
|
411
|
-
const data = await graphqlFetch(
|
|
412
|
-
graphqlUrl,
|
|
413
|
-
token,
|
|
414
|
-
`query GetApps { apps(first: 100) { edges { node { id name } } } }`,
|
|
415
|
-
);
|
|
416
|
-
const apps = data.apps as Record<string, unknown> | undefined;
|
|
417
|
-
const edges = (apps?.edges ?? []) as Array<Record<string, unknown>>;
|
|
418
|
-
return edges.map((edge) => edge.node as { id: string; name: string });
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** All PermissionEnum values supported by the instance. */
|
|
422
|
-
async function queryPermissionEnum(
|
|
423
|
-
graphqlUrl: string,
|
|
424
|
-
token: string,
|
|
425
|
-
): Promise<string[]> {
|
|
426
|
-
const data = await graphqlFetch(
|
|
427
|
-
graphqlUrl,
|
|
428
|
-
token,
|
|
429
|
-
`query { __type(name: "PermissionEnum") { enumValues { name } } }`,
|
|
430
|
-
);
|
|
431
|
-
const type = data.__type as Record<string, unknown> | undefined;
|
|
432
|
-
const values = (type?.enumValues ?? []) as Array<Record<string, unknown>>;
|
|
433
|
-
return values.map((value) => String(value.name));
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** mutation appTokenCreate — create a token for an existing local app. */
|
|
437
|
-
export async function createAppToken(
|
|
438
|
-
graphqlUrl: string,
|
|
439
|
-
token: string,
|
|
440
|
-
appId: string,
|
|
441
|
-
): Promise<{ authToken: string }> {
|
|
442
|
-
const data = await graphqlFetch(
|
|
443
|
-
graphqlUrl,
|
|
444
|
-
token,
|
|
445
|
-
`mutation AppTokenCreate($app: ID!) {
|
|
446
|
-
appTokenCreate(input: { app: $app }) {
|
|
447
|
-
authToken
|
|
448
|
-
errors { field message }
|
|
449
|
-
}
|
|
450
|
-
}`,
|
|
451
|
-
{ app: appId },
|
|
452
|
-
);
|
|
453
|
-
const result = data.appTokenCreate as Record<string, unknown> | undefined;
|
|
454
|
-
const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
|
|
455
|
-
if (errors.length > 0) {
|
|
456
|
-
throw new CloudApiError(
|
|
457
|
-
`appTokenCreate failed: ${errors.map((e) => e.message).join("; ")}`,
|
|
458
|
-
"APP_TOKEN_CREATE_FAILED",
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
const authToken = result?.authToken;
|
|
462
|
-
if (typeof authToken !== "string" || authToken.length === 0) {
|
|
463
|
-
throw new CloudApiError(
|
|
464
|
-
"appTokenCreate did not return an authToken",
|
|
465
|
-
"APP_TOKEN_CREATE_FAILED",
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
return { authToken };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** mutation appCreate — create a local app; returns its auth token directly. */
|
|
472
|
-
export async function createLocalApp(
|
|
473
|
-
graphqlUrl: string,
|
|
474
|
-
token: string,
|
|
475
|
-
name: string,
|
|
476
|
-
permissions: string[],
|
|
477
|
-
): Promise<{ appId: string; authToken: string }> {
|
|
478
|
-
const data = await graphqlFetch(
|
|
479
|
-
graphqlUrl,
|
|
480
|
-
token,
|
|
481
|
-
`mutation AppCreate($input: AppInput!) {
|
|
482
|
-
appCreate(input: $input) {
|
|
483
|
-
authToken
|
|
484
|
-
app { id name }
|
|
485
|
-
errors { field message }
|
|
486
|
-
}
|
|
487
|
-
}`,
|
|
488
|
-
{ input: { name, permissions } },
|
|
489
|
-
);
|
|
490
|
-
const result = data.appCreate as Record<string, unknown> | undefined;
|
|
491
|
-
const errors = (result?.errors ?? []) as Array<Record<string, unknown>>;
|
|
492
|
-
if (errors.length > 0) {
|
|
493
|
-
throw new CloudApiError(
|
|
494
|
-
`appCreate failed: ${errors.map((e) => e.message).join("; ")}`,
|
|
495
|
-
"APP_CREATE_FAILED",
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
const app = result?.app as Record<string, unknown> | undefined;
|
|
499
|
-
const authToken = result?.authToken;
|
|
500
|
-
if (typeof authToken !== "string" || authToken.length === 0) {
|
|
501
|
-
throw new CloudApiError(
|
|
502
|
-
"appCreate did not return an authToken",
|
|
503
|
-
"APP_CREATE_FAILED",
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
return { appId: String(app?.id ?? ""), authToken };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Acquire an app token on the instance: select an existing local app and
|
|
511
|
-
* create a token for it, otherwise create a local app (which yields its
|
|
512
|
-
* token directly) — the deprecated CLI's example flow. Retries transient
|
|
513
|
-
* failures: a freshly provisioned environment can take a moment to serve
|
|
514
|
-
* GraphQL.
|
|
515
|
-
*/
|
|
516
|
-
export async function acquireAppToken(
|
|
517
|
-
graphqlUrl: string,
|
|
518
|
-
token: string,
|
|
519
|
-
appName: string,
|
|
520
|
-
): Promise<string> {
|
|
521
|
-
const apps = await withRetries(() => queryGetApps(graphqlUrl, token));
|
|
522
|
-
if (apps.length > 0) {
|
|
523
|
-
const { authToken } = await createAppToken(graphqlUrl, token, apps[0].id);
|
|
524
|
-
return authToken;
|
|
525
|
-
}
|
|
526
|
-
const permissions = await queryPermissionEnum(graphqlUrl, token);
|
|
527
|
-
const { authToken } = await createLocalApp(
|
|
528
|
-
graphqlUrl,
|
|
529
|
-
token,
|
|
530
|
-
appName,
|
|
531
|
-
permissions,
|
|
532
|
-
);
|
|
533
|
-
return authToken;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
async function withRetries<T>(
|
|
537
|
-
fn: () => Promise<T>,
|
|
538
|
-
attempts: number = 5,
|
|
539
|
-
delayMs: number = 5_000,
|
|
540
|
-
): Promise<T> {
|
|
541
|
-
let lastError: unknown;
|
|
542
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
543
|
-
try {
|
|
544
|
-
return await fn();
|
|
545
|
-
} catch (error) {
|
|
546
|
-
lastError = error;
|
|
547
|
-
if (attempt < attempts - 1) await sleep(delayMs);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
throw lastError;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function sleep(ms: number): Promise<void> {
|
|
554
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
555
|
-
}
|
package/src/lib/env-file.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Local .env secret handling (AGENTS.md "Secret and Environment Handling").
|
|
2
|
-
//
|
|
3
|
-
// Pinned harness seam (see features/step_definitions/005-stripe-checkout-setup.steps.ts):
|
|
4
|
-
// writeEnvValues(projectDir, values) — ensures .env is ignored by Git BEFORE
|
|
5
|
-
// writing, merges the given values into .env, and returns the full loaded
|
|
6
|
-
// post-update value map so the current command flow can use them.
|
|
7
|
-
// loadEnvValues(projectDir) — parses .env into a name → value record.
|
|
8
|
-
//
|
|
9
|
-
// The values handled here are secrets: they must never be logged or printed;
|
|
10
|
-
// Jolly output references them by name only.
|
|
11
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
|
|
14
|
-
const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
|
15
|
-
|
|
16
|
-
/** Parse the project's .env into a name → value record (empty when absent). */
|
|
17
|
-
export function loadEnvValues(projectDir: string): Record<string, string> {
|
|
18
|
-
const path = join(projectDir, ".env");
|
|
19
|
-
if (!existsSync(path)) return {};
|
|
20
|
-
const values: Record<string, string> = {};
|
|
21
|
-
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
22
|
-
const match = ENV_LINE.exec(line);
|
|
23
|
-
if (match) values[match[1]] = match[2];
|
|
24
|
-
}
|
|
25
|
-
return values;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Make sure .gitignore exists and lists `.env` so secrets are never committed. */
|
|
29
|
-
function ensureEnvIgnored(projectDir: string): void {
|
|
30
|
-
const path = join(projectDir, ".gitignore");
|
|
31
|
-
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
32
|
-
const alreadyIgnored = existing.split("\n").some((line) => line.trim() === ".env");
|
|
33
|
-
if (alreadyIgnored) return;
|
|
34
|
-
const prefix = existing.length > 0 && !existing.endsWith("\n") ? `${existing}\n` : existing;
|
|
35
|
-
writeFileSync(path, `${prefix}.env\n`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Merge values into the project's .env, ensuring .env is Git-ignored before
|
|
40
|
-
* any secret touches disk. Existing variables are updated in place, new ones
|
|
41
|
-
* appended, and unrelated lines (comments, other variables) are preserved.
|
|
42
|
-
* Returns the full post-update value map for the current command flow.
|
|
43
|
-
*/
|
|
44
|
-
export function writeEnvValues(
|
|
45
|
-
projectDir: string,
|
|
46
|
-
values: Record<string, string>,
|
|
47
|
-
): Record<string, string> {
|
|
48
|
-
ensureEnvIgnored(projectDir);
|
|
49
|
-
const path = join(projectDir, ".env");
|
|
50
|
-
const lines = existsSync(path) ? readFileSync(path, "utf8").split("\n") : [];
|
|
51
|
-
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
52
|
-
|
|
53
|
-
const pending = { ...values };
|
|
54
|
-
const updated = lines.map((line) => {
|
|
55
|
-
const match = ENV_LINE.exec(line);
|
|
56
|
-
if (match && match[1] in pending) {
|
|
57
|
-
const replacement = `${match[1]}=${pending[match[1]]}`;
|
|
58
|
-
delete pending[match[1]];
|
|
59
|
-
return replacement;
|
|
60
|
-
}
|
|
61
|
-
return line;
|
|
62
|
-
});
|
|
63
|
-
for (const [name, value] of Object.entries(pending)) {
|
|
64
|
-
updated.push(`${name}=${value}`);
|
|
65
|
-
}
|
|
66
|
-
writeFileSync(path, `${updated.join("\n")}\n`);
|
|
67
|
-
return loadEnvValues(projectDir);
|
|
68
|
-
}
|