@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.
@@ -0,0 +1,159 @@
1
+ import { requestJson } from "./http.mjs";
2
+ import { VERSION } from "../../version.mjs";
3
+
4
+ const API_BASE_URL = "https://ploi.io/api";
5
+ const MAX_PAGES = 100;
6
+
7
+ export function createPloiClient(options) {
8
+ const token = required(options.token, "PLOI_API_TOKEN");
9
+ const fetchImplementation = options.fetchImplementation;
10
+
11
+ async function request(path, options = {}) {
12
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
13
+ return requestJson(withQuery(toApiUrl(path), options.query), {
14
+ fetchImplementation,
15
+ method: options.method,
16
+ body,
17
+ acceptedStatuses: options.acceptedStatuses,
18
+ headers: {
19
+ Authorization: `Bearer ${token}`,
20
+ Accept: "application/json",
21
+ "User-Agent": `getquick-site/${VERSION}`,
22
+ ...(body === undefined && (!options.method || options.method === "GET")
23
+ ? {}
24
+ : { "Content-Type": "application/json" }),
25
+ },
26
+ errorMessage(response, payload) {
27
+ return payload.message || `Ploi request failed with HTTP ${response.status}.`;
28
+ },
29
+ });
30
+ }
31
+
32
+ async function list(path, options = {}) {
33
+ const items = [];
34
+ const visited = new Set();
35
+ let next = withQuery(toApiUrl(path), options.query);
36
+
37
+ while (next) {
38
+ if (visited.size >= (options.maxPages || MAX_PAGES)) {
39
+ throw new Error(`Ploi pagination exceeded ${options.maxPages || MAX_PAGES} pages.`);
40
+ }
41
+ if (visited.has(next)) throw new Error("Ploi pagination returned a repeated URL.");
42
+ visited.add(next);
43
+
44
+ const payload = await request(next);
45
+ if (!Array.isArray(payload.data)) {
46
+ throw new Error("Ploi paginated response did not contain a data array.");
47
+ }
48
+ items.push(...payload.data);
49
+ next = payload.links?.next || payload.meta?.next_page_url || null;
50
+ if (next) next = toApiUrl(next);
51
+ }
52
+
53
+ return items;
54
+ }
55
+
56
+ function prepareEndpoint(endpoint, options = {}) {
57
+ const path = fillPath(endpoint.path, options.pathParameters);
58
+ return {
59
+ method: endpoint.method,
60
+ url: withQuery(toApiUrl(path), options.query),
61
+ body: options.body,
62
+ };
63
+ }
64
+
65
+ return {
66
+ prepareEndpoint,
67
+ async executeEndpoint(endpoint, options = {}) {
68
+ const prepared = prepareEndpoint(endpoint, options);
69
+ if (options.paginate) {
70
+ if (endpoint.method !== "GET") {
71
+ throw new Error("Only GET operations can be paginated.");
72
+ }
73
+ return list(prepared.url, { maxPages: options.maxPages });
74
+ }
75
+ return request(prepared.url, {
76
+ method: prepared.method,
77
+ body: prepared.body,
78
+ acceptedStatuses: endpoint.acceptedStatuses,
79
+ });
80
+ },
81
+ async listServers() {
82
+ return list("/servers");
83
+ },
84
+ async getServer(serverId) {
85
+ return unwrap(await request(`/servers/${encodeURIComponent(serverId)}`));
86
+ },
87
+ async listSites(serverId) {
88
+ return list(`/servers/${encodeURIComponent(serverId)}/sites`);
89
+ },
90
+ async getSite(serverId, siteId) {
91
+ return unwrap(
92
+ await request(
93
+ `/servers/${encodeURIComponent(serverId)}/sites/${encodeURIComponent(siteId)}`,
94
+ ),
95
+ );
96
+ },
97
+ };
98
+ }
99
+
100
+ function fillPath(template, values = {}) {
101
+ const placeholders = [...template.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
102
+ const unused = new Set(Object.keys(values));
103
+ let path = template;
104
+
105
+ for (const name of placeholders) {
106
+ const value = values[name];
107
+ if (value === undefined || value === null || String(value).trim() === "") {
108
+ throw new Error(`Ploi API operation requires --path ${name}=<value>.`);
109
+ }
110
+ unused.delete(name);
111
+ path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value)));
112
+ }
113
+
114
+ if (unused.size > 0) {
115
+ throw new Error(
116
+ `Unknown path parameter${unused.size === 1 ? "" : "s"}: ${[...unused].join(", ")}.`,
117
+ );
118
+ }
119
+ return path;
120
+ }
121
+
122
+ function withQuery(url, query = {}) {
123
+ const result = new URL(url);
124
+ for (const [name, value] of Object.entries(query)) {
125
+ const values = Array.isArray(value) ? value : [value];
126
+ for (const entry of values) {
127
+ if (entry !== undefined && entry !== null) {
128
+ result.searchParams.append(name, String(entry));
129
+ }
130
+ }
131
+ }
132
+ return result.href;
133
+ }
134
+
135
+ function toApiUrl(path) {
136
+ let url = /^https?:\/\//.test(path)
137
+ ? new URL(path)
138
+ : path.startsWith("/api/")
139
+ ? new URL(path, new URL(API_BASE_URL).origin)
140
+ : new URL(`${API_BASE_URL}/${path.replace(/^\/+/, "")}`);
141
+
142
+ if (url.protocol === "http:" && url.hostname === "ploi.io") {
143
+ url = new URL(url.href);
144
+ url.protocol = "https:";
145
+ }
146
+ if (url.origin !== new URL(API_BASE_URL).origin || !url.pathname.startsWith("/api/")) {
147
+ throw new Error("Ploi returned an unexpected URL outside its API.");
148
+ }
149
+ return url.href;
150
+ }
151
+
152
+ function unwrap(payload) {
153
+ return payload.data ?? payload;
154
+ }
155
+
156
+ function required(value, environmentName) {
157
+ if (!value?.trim()) throw new Error(`${environmentName} is required.`);
158
+ return value.trim();
159
+ }
package/src/ops/ui.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import {
2
+ autocomplete,
3
+ cancel,
4
+ confirm,
5
+ intro,
6
+ isCancel,
7
+ outro,
8
+ select,
9
+ spinner,
10
+ } from "@clack/prompts";
11
+
12
+ export async function chooseCommand(endpoints) {
13
+ intro("gq operations");
14
+ const command = await select({
15
+ message: "What would you like to inspect?",
16
+ options: [
17
+ { value: "context", label: "Project context" },
18
+ { value: "servers", label: "Ploi servers" },
19
+ { value: "sites", label: "Ploi sites" },
20
+ { value: "ploi-api", label: "Browse the Ploi API" },
21
+ { value: "accounts", label: "Cloudflare accounts" },
22
+ { value: "zones", label: "Cloudflare zones" },
23
+ { value: "dns", label: "Cloudflare DNS records" },
24
+ ],
25
+ });
26
+ if (isCancel(command)) return cancelled();
27
+
28
+ if (command === "ploi-api") {
29
+ const operation = await autocomplete({
30
+ message: "Choose a Ploi operation",
31
+ placeholder: "Search 225 operations…",
32
+ maxItems: 10,
33
+ options: endpoints.map((endpoint) => ({
34
+ value: endpoint.id,
35
+ label: endpoint.id,
36
+ hint: `${endpoint.method} ${endpoint.path}`,
37
+ })),
38
+ });
39
+ if (isCancel(operation)) return cancelled();
40
+ outro("Operation selected");
41
+ return ["ploi", "api", "describe", operation, "--json"];
42
+ }
43
+
44
+ outro("Running command");
45
+ return {
46
+ context: ["context", "show"],
47
+ servers: ["ploi", "servers", "list"],
48
+ sites: ["ploi", "sites", "list"],
49
+ accounts: ["cloudflare", "accounts", "list"],
50
+ zones: ["cloudflare", "zones", "list"],
51
+ dns: ["cloudflare", "dns", "list"],
52
+ }[command];
53
+ }
54
+
55
+ export async function confirmMutation({ operation, method, url }) {
56
+ const answer = await confirm({
57
+ message: `${method} ${url}\nRun ${operation}?`,
58
+ initialValue: false,
59
+ });
60
+ if (isCancel(answer) || !answer) {
61
+ cancel("Operation cancelled. No request was sent.");
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ export async function withSpinner(message, callback, enabled) {
68
+ if (!enabled) return callback();
69
+ const progress = spinner();
70
+ progress.start(message);
71
+ try {
72
+ const result = await callback();
73
+ progress.stop("Done");
74
+ return result;
75
+ } catch (error) {
76
+ progress.error("Failed");
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ function cancelled() {
82
+ cancel("Operation cancelled.");
83
+ return null;
84
+ }
package/src/run.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import { log } from "@clack/prompts";
2
+ import { runCli } from "./ops/cli.mjs";
3
+
4
+ // The whole CLI behind one in-process call: every provider request goes
5
+ // through `fetch`, every child process through `exec`, and every value a
6
+ // command needs from its surroundings comes from `cwd` and `env`; no command
7
+ // reads the process's own environment or directory. bin/gq.mjs is the only
8
+ // caller that wires in the real ones. Resolves to the process exit code.
9
+ export async function run(
10
+ argv,
11
+ {
12
+ cwd,
13
+ env = {},
14
+ fetch = unavailable("fetch"),
15
+ exec = unavailable("exec"),
16
+ stdout,
17
+ stderr,
18
+ interactive = false,
19
+ } = {},
20
+ ) {
21
+ if (typeof cwd !== "string" || cwd === "") throw new TypeError("run() requires a cwd.");
22
+ if (typeof stdout?.write !== "function" || typeof stderr?.write !== "function") {
23
+ throw new TypeError("run() requires stdout and stderr streams.");
24
+ }
25
+ const io = {
26
+ out: (line) => stdout.write(`${line}\n`),
27
+ err: (line) => stderr.write(`${line}\n`),
28
+ };
29
+
30
+ try {
31
+ await runCli(argv, { cwd, env, fetch, exec, io, interactive });
32
+ return 0;
33
+ } catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ if (interactive) log.error(message);
36
+ else io.err(`gq: ${message}`);
37
+ return 1;
38
+ }
39
+ }
40
+
41
+ function unavailable(name) {
42
+ return () => {
43
+ throw new Error(`run() was called without ${name}, and this command needs it.`);
44
+ };
45
+ }
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+
3
+ export const VERSION = packageJson.version;