@getquick/site 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bin/gq.mjs +14 -0
- package/docs/research/ploi-api.md +2371 -0
- package/package.json +56 -4
- package/src/exec.mjs +23 -0
- package/src/index.mjs +1 -0
- package/src/ops/cli.mjs +349 -0
- package/src/ops/env.mjs +73 -0
- package/src/ops/output.mjs +62 -0
- package/src/ops/ploi-api-command.mjs +225 -0
- package/src/ops/project-context.mjs +103 -0
- package/src/ops/providers/cloudflare.mjs +95 -0
- package/src/ops/providers/github.mjs +104 -0
- package/src/ops/providers/http.mjs +31 -0
- package/src/ops/providers/ploi-catalog.mjs +49 -0
- package/src/ops/providers/ploi-endpoints.mjs +1590 -0
- package/src/ops/providers/ploi.mjs +159 -0
- package/src/ops/ui.mjs +84 -0
- package/src/run.mjs +45 -0
- package/src/version.mjs +3 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { printValue } from "./output.mjs";
|
|
3
|
+
import { createPloiClient } from "./providers/ploi.mjs";
|
|
4
|
+
import { ploiCatalog } from "./providers/ploi-endpoints.mjs";
|
|
5
|
+
import { confirmMutation, withSpinner } from "./ui.mjs";
|
|
6
|
+
|
|
7
|
+
export const PLOI_API_OPTIONS = [
|
|
8
|
+
"server",
|
|
9
|
+
"site",
|
|
10
|
+
"path",
|
|
11
|
+
"query",
|
|
12
|
+
"data",
|
|
13
|
+
"dataFile",
|
|
14
|
+
"page",
|
|
15
|
+
"perPage",
|
|
16
|
+
"maxPages",
|
|
17
|
+
"all",
|
|
18
|
+
"yes",
|
|
19
|
+
"dryRun",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function isPloiApiCommand(command) {
|
|
23
|
+
return command[0] === "ploi" && command[1] === "api";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validatePloiApiCommand(parsed) {
|
|
27
|
+
const action = parsed.command[2];
|
|
28
|
+
if (!action) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Usage: gq ploi api <operation-id>, gq ploi api list, or gq ploi api describe <operation-id>.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (action === "list") {
|
|
34
|
+
if (parsed.command.length !== 3) {
|
|
35
|
+
throw new Error("gq ploi api list does not accept positional arguments.");
|
|
36
|
+
}
|
|
37
|
+
rejectOptionsExcept(parsed, ["group", "search"]);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action === "describe") {
|
|
41
|
+
if (parsed.command.length !== 4) {
|
|
42
|
+
throw new Error("gq ploi api describe requires an operation ID.");
|
|
43
|
+
}
|
|
44
|
+
rejectOptionsExcept(parsed, []);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (parsed.command.length !== 3) {
|
|
48
|
+
throw new Error(`Unknown Ploi API command: ${parsed.command.join(" ")}.`);
|
|
49
|
+
}
|
|
50
|
+
rejectOptionsExcept(parsed, PLOI_API_OPTIONS);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function runPloiApiCatalogCommand(parsed, io) {
|
|
54
|
+
const action = parsed.command[2];
|
|
55
|
+
if (action === "list") {
|
|
56
|
+
const endpoints = ploiCatalog.list({
|
|
57
|
+
group: parsed.group,
|
|
58
|
+
search: parsed.search,
|
|
59
|
+
});
|
|
60
|
+
printValue(
|
|
61
|
+
io,
|
|
62
|
+
endpoints.map(({ id, method, path, summary }) => ({
|
|
63
|
+
id,
|
|
64
|
+
method,
|
|
65
|
+
path,
|
|
66
|
+
summary,
|
|
67
|
+
})),
|
|
68
|
+
parsed,
|
|
69
|
+
);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (action === "describe") {
|
|
73
|
+
const endpoint = ploiCatalog.get(parsed.command[3]);
|
|
74
|
+
printValue(
|
|
75
|
+
io,
|
|
76
|
+
{
|
|
77
|
+
...endpoint,
|
|
78
|
+
pathParameters: pathParameterNames(endpoint.path).join(", "),
|
|
79
|
+
mutationRequiresConfirmation: endpoint.method !== "GET",
|
|
80
|
+
},
|
|
81
|
+
parsed,
|
|
82
|
+
);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runPloiApiCommand({ context, parsed, fetch, io, interactive }) {
|
|
89
|
+
const endpoint = ploiCatalog.get(parsed.command[2]);
|
|
90
|
+
const pathParameters = parseNameValues(parsed.path, "--path");
|
|
91
|
+
const query = parseNameValues(parsed.query, "--query", { repeated: true });
|
|
92
|
+
applyContextPathDefaults(endpoint, pathParameters, parsed, context);
|
|
93
|
+
if (parsed.page) query.page = positiveInteger(parsed.page, "--page");
|
|
94
|
+
if (parsed.perPage) query.per_page = positiveInteger(parsed.perPage, "--per-page");
|
|
95
|
+
|
|
96
|
+
const body = await readBody(parsed, context);
|
|
97
|
+
const client = createPloiClient({
|
|
98
|
+
token: context.env.PLOI_API_TOKEN,
|
|
99
|
+
fetchImplementation: fetch,
|
|
100
|
+
});
|
|
101
|
+
const request = client.prepareEndpoint(endpoint, {
|
|
102
|
+
pathParameters,
|
|
103
|
+
query,
|
|
104
|
+
body,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (parsed.dryRun) {
|
|
108
|
+
printValue(
|
|
109
|
+
io,
|
|
110
|
+
{
|
|
111
|
+
operation: endpoint.id,
|
|
112
|
+
method: request.method,
|
|
113
|
+
url: request.url,
|
|
114
|
+
body: request.body,
|
|
115
|
+
},
|
|
116
|
+
{ json: true },
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (endpoint.method !== "GET" && !parsed.yes) {
|
|
122
|
+
if (!interactive) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${endpoint.id} changes remote state. Re-run with --yes or inspect it with --dry-run.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const confirmed = await confirmMutation({
|
|
128
|
+
operation: endpoint.id,
|
|
129
|
+
method: request.method,
|
|
130
|
+
url: request.url,
|
|
131
|
+
});
|
|
132
|
+
if (!confirmed) return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await withSpinner(
|
|
136
|
+
`Running ${endpoint.id}`,
|
|
137
|
+
() =>
|
|
138
|
+
client.executeEndpoint(endpoint, {
|
|
139
|
+
pathParameters,
|
|
140
|
+
query,
|
|
141
|
+
body,
|
|
142
|
+
paginate: parsed.all,
|
|
143
|
+
maxPages: parsed.maxPages ? positiveInteger(parsed.maxPages, "--max-pages") : undefined,
|
|
144
|
+
}),
|
|
145
|
+
interactive,
|
|
146
|
+
);
|
|
147
|
+
printValue(io, result, { json: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readBody(parsed, context) {
|
|
151
|
+
if (parsed.data !== undefined && parsed.dataFile !== undefined) {
|
|
152
|
+
throw new Error("Use either --data or --data-file, not both.");
|
|
153
|
+
}
|
|
154
|
+
if (parsed.data !== undefined) return parseJson(parsed.data, "--data");
|
|
155
|
+
if (parsed.dataFile !== undefined) {
|
|
156
|
+
const path = context.resolvePath(parsed.dataFile);
|
|
157
|
+
return parseJson(await readFile(path, "utf8"), path);
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function applyContextPathDefaults(endpoint, values, parsed, context) {
|
|
163
|
+
const placeholders = new Set(pathParameterNames(endpoint.path));
|
|
164
|
+
if (placeholders.has("server") && values.server === undefined) {
|
|
165
|
+
values.server = parsed.server || context.env.PLOI_SERVER_ID || context.config.ploi?.serverId;
|
|
166
|
+
}
|
|
167
|
+
if (placeholders.has("site") && values.site === undefined) {
|
|
168
|
+
values.site = parsed.site || context.env.PLOI_SITE_ID || context.config.ploi?.siteId;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pathParameterNames(path) {
|
|
173
|
+
return [...new Set([...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]))];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseNameValues(entries, option, { repeated = false } = {}) {
|
|
177
|
+
const result = {};
|
|
178
|
+
for (const entry of entries || []) {
|
|
179
|
+
const separator = entry.indexOf("=");
|
|
180
|
+
if (separator <= 0) {
|
|
181
|
+
throw new Error(`${option} must use name=value syntax.`);
|
|
182
|
+
}
|
|
183
|
+
const name = entry.slice(0, separator);
|
|
184
|
+
const value = entry.slice(separator + 1);
|
|
185
|
+
if (!repeated && Object.hasOwn(result, name)) {
|
|
186
|
+
throw new Error(`${option} ${name} was provided more than once.`);
|
|
187
|
+
}
|
|
188
|
+
if (repeated && Object.hasOwn(result, name)) {
|
|
189
|
+
result[name] = Array.isArray(result[name]) ? [...result[name], value] : [result[name], value];
|
|
190
|
+
} else {
|
|
191
|
+
result[name] = value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseJson(value, source) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(value);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new Error(`${source} must contain valid JSON: ${error.message}`, {
|
|
202
|
+
cause: error,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function positiveInteger(value, option) {
|
|
208
|
+
const parsed = Number(value);
|
|
209
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
210
|
+
throw new Error(`${option} must be a positive integer.`);
|
|
211
|
+
}
|
|
212
|
+
return parsed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rejectOptionsExcept(parsed, allowed) {
|
|
216
|
+
const globals = new Set(["command", "help", "json", "project", "config"]);
|
|
217
|
+
const accepted = new Set(allowed);
|
|
218
|
+
for (const key of Object.keys(parsed)) {
|
|
219
|
+
if (!globals.has(key) && !accepted.has(key)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is not valid for ${parsed.command.join(" ")}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { readEnvFile } from "./env.mjs";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILENAME = "gq.ops.json";
|
|
6
|
+
|
|
7
|
+
export async function findProjectConfig(startDirectory) {
|
|
8
|
+
let directory = await realpath(resolve(startDirectory));
|
|
9
|
+
|
|
10
|
+
while (true) {
|
|
11
|
+
const candidate = join(directory, CONFIG_FILENAME);
|
|
12
|
+
if (await exists(candidate)) return candidate;
|
|
13
|
+
if (await exists(join(directory, ".git"))) return null;
|
|
14
|
+
|
|
15
|
+
const parent = dirname(directory);
|
|
16
|
+
if (parent === directory) return null;
|
|
17
|
+
directory = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `cwd` and `env` are the run() seam's; nothing here reads the process's own.
|
|
22
|
+
export async function loadProjectContext(options) {
|
|
23
|
+
const configPath = await resolveConfigPath(options);
|
|
24
|
+
if (!configPath) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`No ${CONFIG_FILENAME} found from ${resolve(options.cwd)}. ` +
|
|
27
|
+
"Run inside a configured project or pass --project/--config.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const canonicalConfigPath = await realpath(configPath);
|
|
32
|
+
const projectRoot = dirname(canonicalConfigPath);
|
|
33
|
+
const config = validateConfig(await readConfig(canonicalConfigPath));
|
|
34
|
+
const machineEnvPath = getMachineEnvPath(options.env);
|
|
35
|
+
const [machineEnv, projectEnv] = await Promise.all([
|
|
36
|
+
machineEnvPath ? readEnvFile(machineEnvPath) : {},
|
|
37
|
+
readEnvFile(join(projectRoot, ".env")),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
config,
|
|
42
|
+
configPath: canonicalConfigPath,
|
|
43
|
+
projectRoot,
|
|
44
|
+
invocationDirectory: await realpath(resolve(options.cwd)),
|
|
45
|
+
machineEnvPath,
|
|
46
|
+
env: {
|
|
47
|
+
...machineEnv,
|
|
48
|
+
...projectEnv,
|
|
49
|
+
...options.env,
|
|
50
|
+
},
|
|
51
|
+
resolvePath(path) {
|
|
52
|
+
return isAbsolute(path) ? path : resolve(projectRoot, path);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveConfigPath(options) {
|
|
58
|
+
if (options.config) return resolve(options.cwd, options.config);
|
|
59
|
+
if (options.project) {
|
|
60
|
+
const project = resolve(options.cwd, options.project);
|
|
61
|
+
return project.endsWith(CONFIG_FILENAME) ? project : join(project, CONFIG_FILENAME);
|
|
62
|
+
}
|
|
63
|
+
return findProjectConfig(options.cwd);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readConfig(path) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof SyntaxError) {
|
|
71
|
+
throw new Error(`${path} is not valid JSON: ${error.message}`, {
|
|
72
|
+
cause: error,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateConfig(config) {
|
|
80
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
81
|
+
throw new Error(`${CONFIG_FILENAME} must contain a configuration object.`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof config.project !== "string" || !config.project.trim()) {
|
|
84
|
+
throw new Error(`${CONFIG_FILENAME} must define a non-empty project name.`);
|
|
85
|
+
}
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Without XDG_CONFIG_HOME or HOME there is no machine file to read.
|
|
90
|
+
function getMachineEnvPath(environment) {
|
|
91
|
+
const configHome =
|
|
92
|
+
environment.XDG_CONFIG_HOME || (environment.HOME ? join(environment.HOME, ".config") : null);
|
|
93
|
+
return configHome ? join(configHome, "gq", "ops.env") : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function exists(path) {
|
|
97
|
+
try {
|
|
98
|
+
await access(path);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { requestJson } from "./http.mjs";
|
|
2
|
+
|
|
3
|
+
const API_BASE_URL = "https://api.cloudflare.com/client/v4";
|
|
4
|
+
|
|
5
|
+
export function createCloudflareClient(options) {
|
|
6
|
+
const token = required(options.token, "CLOUDFLARE_API_TOKEN");
|
|
7
|
+
const fetchImplementation = options.fetchImplementation;
|
|
8
|
+
|
|
9
|
+
async function request(path, query = {}) {
|
|
10
|
+
const url = toApiUrl(path);
|
|
11
|
+
for (const [key, value] of Object.entries(query)) {
|
|
12
|
+
if (value !== undefined && value !== "") url.searchParams.set(key, value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const payload = await requestJson(url, {
|
|
16
|
+
fetchImplementation,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${token}`,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
},
|
|
21
|
+
errorMessage(response, data) {
|
|
22
|
+
return (
|
|
23
|
+
data.errors
|
|
24
|
+
?.map((error) => error.message)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("; ") || `Cloudflare request failed with HTTP ${response.status}.`
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (payload.success === false) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
payload.errors
|
|
34
|
+
?.map((error) => error.message)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("; ") || "Cloudflare reported an unsuccessful request.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return payload;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function list(path, query = {}) {
|
|
43
|
+
const items = [];
|
|
44
|
+
let page = 1;
|
|
45
|
+
|
|
46
|
+
while (true) {
|
|
47
|
+
if (page > 100) throw new Error("Cloudflare pagination exceeded 100 pages.");
|
|
48
|
+
const payload = await request(path, {
|
|
49
|
+
...query,
|
|
50
|
+
page: String(page),
|
|
51
|
+
per_page: "50",
|
|
52
|
+
});
|
|
53
|
+
if (Array.isArray(payload.result)) items.push(...payload.result);
|
|
54
|
+
const pageInfo = payload.result_info || {};
|
|
55
|
+
const perPage = Number(pageInfo.per_page) || 50;
|
|
56
|
+
const totalPages =
|
|
57
|
+
Number(pageInfo.total_pages) ||
|
|
58
|
+
Math.max(1, Math.ceil((Number(pageInfo.total_count) || items.length) / perPage));
|
|
59
|
+
if (page >= totalPages) return items;
|
|
60
|
+
page += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async listAccounts() {
|
|
66
|
+
return list("/accounts");
|
|
67
|
+
},
|
|
68
|
+
async listZones(filters = {}) {
|
|
69
|
+
return list("/zones", filters);
|
|
70
|
+
},
|
|
71
|
+
async getZone(zoneId) {
|
|
72
|
+
return unwrap(await request(`/zones/${encodeURIComponent(zoneId)}`));
|
|
73
|
+
},
|
|
74
|
+
async listDnsRecords(zoneId, filters = {}) {
|
|
75
|
+
return list(`/zones/${encodeURIComponent(zoneId)}/dns_records`, filters);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toApiUrl(path) {
|
|
81
|
+
const url = new URL(`${API_BASE_URL}/${path.replace(/^\/+/, "")}`);
|
|
82
|
+
if (url.origin !== new URL(API_BASE_URL).origin || !url.pathname.startsWith("/client/v4/")) {
|
|
83
|
+
throw new Error("Cloudflare request resolved outside the expected API.");
|
|
84
|
+
}
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function unwrap(payload) {
|
|
89
|
+
return payload.result ?? payload;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function required(value, environmentName) {
|
|
93
|
+
if (!value?.trim()) throw new Error(`${environmentName} is required.`);
|
|
94
|
+
return value.trim();
|
|
95
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export async function syncActionsValues({
|
|
2
|
+
context,
|
|
3
|
+
env,
|
|
4
|
+
exec,
|
|
5
|
+
dryRun = false,
|
|
6
|
+
yes = false,
|
|
7
|
+
interactive = false,
|
|
8
|
+
}) {
|
|
9
|
+
const github = context.config.github;
|
|
10
|
+
const repository = github?.repository;
|
|
11
|
+
const environment = github?.environment || "production";
|
|
12
|
+
const secrets = normalizeEntries(github?.secrets);
|
|
13
|
+
const variables = normalizeEntries(github?.variables);
|
|
14
|
+
|
|
15
|
+
if (!repository) throw new Error("gq.ops.json must define github.repository.");
|
|
16
|
+
if (!secrets || !variables) {
|
|
17
|
+
throw new Error("github.secrets and github.variables must be arrays.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const values = context.env;
|
|
21
|
+
const activeSecrets = configuredEntries(secrets, values);
|
|
22
|
+
const activeVariables = configuredEntries(variables, values);
|
|
23
|
+
const missingSecrets = missingValues(secrets, values);
|
|
24
|
+
const missingVariables = missingValues(variables, values);
|
|
25
|
+
if (missingSecrets.length > 0 || missingVariables.length > 0) {
|
|
26
|
+
const missing = [
|
|
27
|
+
...missingSecrets.map((name) => `secret ${name}`),
|
|
28
|
+
...missingVariables.map((name) => `variable ${name}`),
|
|
29
|
+
];
|
|
30
|
+
throw new Error(`Missing values in .env or the process environment: ${missing.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const plan = {
|
|
34
|
+
repository,
|
|
35
|
+
environment,
|
|
36
|
+
secrets: activeSecrets.map(({ name }) => name),
|
|
37
|
+
variables: activeVariables.map(({ name }) => name),
|
|
38
|
+
};
|
|
39
|
+
if (dryRun) return { ...plan, applied: false };
|
|
40
|
+
|
|
41
|
+
if (!yes && interactive) {
|
|
42
|
+
const { confirm } = await import("@clack/prompts");
|
|
43
|
+
const answer = await confirm({
|
|
44
|
+
message: `Sync ${secrets.length} secrets and ${variables.length} variables to ${repository}/${environment}?`,
|
|
45
|
+
initialValue: false,
|
|
46
|
+
});
|
|
47
|
+
if (answer !== true) throw new Error("GitHub synchronization cancelled.");
|
|
48
|
+
} else if (!yes && !interactive) {
|
|
49
|
+
throw new Error("GitHub synchronization requires --yes outside an interactive terminal.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const { name } of activeSecrets) {
|
|
53
|
+
await runGh(["secret", "set", name, "--repo", repository, "--env", environment], values[name], {
|
|
54
|
+
exec,
|
|
55
|
+
env,
|
|
56
|
+
tokens: context.env,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const { name } of activeVariables) {
|
|
60
|
+
await runGh(
|
|
61
|
+
["variable", "set", name, "--repo", repository, "--env", environment],
|
|
62
|
+
values[name],
|
|
63
|
+
{ exec, env, tokens: context.env },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ...plan, applied: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The value goes to gh on stdin, never in its arguments.
|
|
71
|
+
async function runGh(args, input, { exec, env, tokens }) {
|
|
72
|
+
const { code, stdout, stderr } = await exec("gh", args, {
|
|
73
|
+
env: {
|
|
74
|
+
...env,
|
|
75
|
+
...(tokens.GITHUB_TOKEN ? { GH_TOKEN: tokens.GITHUB_TOKEN } : {}),
|
|
76
|
+
...(tokens.GH_TOKEN ? { GH_TOKEN: tokens.GH_TOKEN } : {}),
|
|
77
|
+
},
|
|
78
|
+
input: String(input),
|
|
79
|
+
});
|
|
80
|
+
if (code !== 0) {
|
|
81
|
+
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(stderr || stdout).trim()}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeEntries(entries) {
|
|
86
|
+
if (!Array.isArray(entries)) return null;
|
|
87
|
+
return entries.map((entry) => {
|
|
88
|
+
if (typeof entry === "string") return { name: entry, required: true };
|
|
89
|
+
if (entry && typeof entry.name === "string") {
|
|
90
|
+
return { name: entry.name, required: entry.required !== false };
|
|
91
|
+
}
|
|
92
|
+
throw new Error("GitHub sync entries must be names or { name, required } objects.");
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function configuredEntries(entries, values) {
|
|
97
|
+
return entries.filter(({ name }) => values[name] !== undefined && values[name] !== "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function missingValues(entries, values) {
|
|
101
|
+
return entries
|
|
102
|
+
.filter(({ name, required }) => required && (values[name] === undefined || values[name] === ""))
|
|
103
|
+
.map(({ name }) => name);
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export async function requestJson(url, options = {}) {
|
|
2
|
+
const response = await options.fetchImplementation(url, {
|
|
3
|
+
method: options.method || "GET",
|
|
4
|
+
signal: options.signal || AbortSignal.timeout(options.timeoutMs || 30_000),
|
|
5
|
+
headers: options.headers,
|
|
6
|
+
body: options.body,
|
|
7
|
+
});
|
|
8
|
+
const text = await response.text();
|
|
9
|
+
const parsed = text ? tryParseJson(text) : { ok: true, value: {} };
|
|
10
|
+
|
|
11
|
+
const acceptedStatus = options.acceptedStatuses?.includes(response.status);
|
|
12
|
+
if (!response.ok && !acceptedStatus) {
|
|
13
|
+
const payload = parsed.ok ? parsed.value : {};
|
|
14
|
+
throw new Error(
|
|
15
|
+
options.errorMessage?.(response, payload) || `Request failed with HTTP ${response.status}.`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (!parsed.ok) {
|
|
19
|
+
throw new Error("Provider returned a response that was not valid JSON.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return parsed.value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tryParseJson(text) {
|
|
26
|
+
try {
|
|
27
|
+
return { ok: true, value: JSON.parse(text) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ok: false };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function createPloiCatalog(endpoints) {
|
|
2
|
+
const byId = new Map();
|
|
3
|
+
|
|
4
|
+
for (const endpoint of endpoints) {
|
|
5
|
+
validateEndpoint(endpoint);
|
|
6
|
+
if (byId.has(endpoint.id)) {
|
|
7
|
+
throw new Error(`Duplicate Ploi endpoint ID: ${endpoint.id}.`);
|
|
8
|
+
}
|
|
9
|
+
byId.set(endpoint.id, Object.freeze({ ...endpoint }));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return Object.freeze({
|
|
13
|
+
list({ group, search } = {}) {
|
|
14
|
+
const needle = search?.trim().toLowerCase();
|
|
15
|
+
return [...byId.values()].filter((endpoint) => {
|
|
16
|
+
if (group && endpoint.id.split(".", 1)[0] !== group) return false;
|
|
17
|
+
if (!needle) return true;
|
|
18
|
+
return [endpoint.id, endpoint.method, endpoint.path, endpoint.summary]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.some((value) => value.toLowerCase().includes(needle));
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
get(id) {
|
|
24
|
+
const endpoint = byId.get(id);
|
|
25
|
+
if (!endpoint) {
|
|
26
|
+
throw new Error(`Unknown Ploi API operation: ${id}. Run gq ploi api list.`);
|
|
27
|
+
}
|
|
28
|
+
return endpoint;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateEndpoint(endpoint) {
|
|
34
|
+
if (!endpoint || typeof endpoint !== "object") {
|
|
35
|
+
throw new Error("Ploi endpoint entries must be objects.");
|
|
36
|
+
}
|
|
37
|
+
if (!/^[a-z0-9-]+\.[a-z0-9-]+$/.test(endpoint.id)) {
|
|
38
|
+
throw new Error(`Invalid Ploi endpoint ID: ${endpoint.id}.`);
|
|
39
|
+
}
|
|
40
|
+
if (!new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]).has(endpoint.method)) {
|
|
41
|
+
throw new Error(`Invalid HTTP method for ${endpoint.id}: ${endpoint.method}.`);
|
|
42
|
+
}
|
|
43
|
+
if (!endpoint.path.startsWith("/") || endpoint.path.includes("?")) {
|
|
44
|
+
throw new Error(`Invalid API path for ${endpoint.id}: ${endpoint.path}.`);
|
|
45
|
+
}
|
|
46
|
+
if (!endpoint.sourceUrl.startsWith("https://developers.ploi.io/")) {
|
|
47
|
+
throw new Error(`Invalid documentation URL for ${endpoint.id}.`);
|
|
48
|
+
}
|
|
49
|
+
}
|