greybull 0.1.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 +70 -0
- package/dist/index.js +520 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# greybull
|
|
2
|
+
|
|
3
|
+
Command-line tool for the [Greybull](https://dash.greybull.nl) portal — manage DNS records and domains from your terminal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx greybull dns zones
|
|
7
|
+
# or install it
|
|
8
|
+
npm install -g greybull
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Login
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
greybull login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Opens your browser, asks you to approve, and stores a scoped API key
|
|
18
|
+
(`dns:read`, `dns:write`, `domains:read`) in `~/.config/greybull/config.json`
|
|
19
|
+
(mode `0600`).
|
|
20
|
+
|
|
21
|
+
Alternatives:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
greybull login --with-key # paste an API key instead of using the browser
|
|
25
|
+
GREYBULL_API_KEY=ck_... greybull … # env var, for CI / headless (no stored state)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Create keys manually at <https://dash.greybull.nl/api-settings>.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `greybull login` / `logout` / `whoami` | Authentication |
|
|
35
|
+
| `greybull dns zones` | List your DNS zones |
|
|
36
|
+
| `greybull dns list <zone> [--type T] [--name N]` | List records |
|
|
37
|
+
| `greybull dns add <zone> <type> <name> <content> [--ttl N] [--priority N]` | Add a record |
|
|
38
|
+
| `greybull dns update <zone> <type> <name> <old> <new> [--ttl N] [--priority N] [--new-name X]` | Change one record |
|
|
39
|
+
| `greybull dns delete <zone> <type> <name> <content> [--priority N]` | Delete one record |
|
|
40
|
+
| `greybull domains list` | List registered domains |
|
|
41
|
+
| `greybull domains check <query> [--tlds nl,com]` | Check availability |
|
|
42
|
+
|
|
43
|
+
Global flags: `--json` (raw JSON output), `--api-url <url>` (or `GREYBULL_API_URL`, for self-hosted/dev).
|
|
44
|
+
|
|
45
|
+
## Examples
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
greybull dns list example.nl --type TXT
|
|
49
|
+
greybull dns add example.nl TXT @ '"v=spf1 include:_spf.example.com -all"'
|
|
50
|
+
greybull dns add example.nl MX @ mail.example.nl --priority 10
|
|
51
|
+
greybull dns update example.nl A www 203.0.113.10 203.0.113.20
|
|
52
|
+
greybull dns delete example.nl TXT _acme-challenge '"token123"'
|
|
53
|
+
greybull domains check mycompany --tlds nl,com,io
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Record semantics
|
|
57
|
+
|
|
58
|
+
Record operations are **sibling-safe**: they touch only the specific record you
|
|
59
|
+
name (identified by name + type + exact content), never the whole record set. Two
|
|
60
|
+
TXT records at the same name stay independent — updating or deleting one leaves the
|
|
61
|
+
other alone. That's why `update` and `delete` require the exact current content.
|
|
62
|
+
|
|
63
|
+
TXT content usually needs quoting (`'"..."'`); the CLI warns if it looks unquoted.
|
|
64
|
+
DNS validation errors come straight from PowerDNS so you see the real reason.
|
|
65
|
+
|
|
66
|
+
## Config & environment
|
|
67
|
+
|
|
68
|
+
- `~/.config/greybull/config.json` — `{ "apiKey": "...", "apiUrl": "..." }`, `0600`.
|
|
69
|
+
- `GREYBULL_API_KEY` — overrides the stored key.
|
|
70
|
+
- `GREYBULL_API_URL` — overrides the base URL (default `https://dash.greybull.nl`).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
var DEFAULT_API_URL = "https://dash.greybull.nl";
|
|
11
|
+
var CONFIG_DIR = path.join(os.homedir(), ".config", "greybull");
|
|
12
|
+
var CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
13
|
+
function loadConfig() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
const e = err;
|
|
19
|
+
if (e.code === "ENOENT") return {};
|
|
20
|
+
process.stderr.write(`Warning: could not read ${CONFIG_PATH}, ignoring it.
|
|
21
|
+
`);
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function saveConfig(config) {
|
|
26
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
27
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
28
|
+
fs.chmodSync(CONFIG_PATH, 384);
|
|
29
|
+
}
|
|
30
|
+
function clearApiKey() {
|
|
31
|
+
const config = loadConfig();
|
|
32
|
+
delete config.apiKey;
|
|
33
|
+
saveConfig(config);
|
|
34
|
+
}
|
|
35
|
+
function configPath() {
|
|
36
|
+
return CONFIG_PATH;
|
|
37
|
+
}
|
|
38
|
+
function resolveApiKey() {
|
|
39
|
+
return process.env.GREYBULL_API_KEY || loadConfig().apiKey || null;
|
|
40
|
+
}
|
|
41
|
+
function stripTrailingSlash(url) {
|
|
42
|
+
return url.replace(/\/+$/, "");
|
|
43
|
+
}
|
|
44
|
+
function resolveApiUrl(flagValue) {
|
|
45
|
+
const url = process.env.GREYBULL_API_URL || flagValue || loadConfig().apiUrl || DEFAULT_API_URL;
|
|
46
|
+
return stripTrailingSlash(url);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/commands/auth.ts
|
|
50
|
+
import http from "http";
|
|
51
|
+
import os2 from "os";
|
|
52
|
+
import readline from "readline/promises";
|
|
53
|
+
|
|
54
|
+
// src/browser.ts
|
|
55
|
+
import { spawn } from "child_process";
|
|
56
|
+
function openBrowser(url) {
|
|
57
|
+
const platform = process.platform;
|
|
58
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
59
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
62
|
+
child.on("error", () => {
|
|
63
|
+
});
|
|
64
|
+
child.unref();
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/pkce.ts
|
|
72
|
+
import crypto from "crypto";
|
|
73
|
+
function randomToken(bytes = 32) {
|
|
74
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
75
|
+
}
|
|
76
|
+
function codeChallenge(verifier) {
|
|
77
|
+
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
78
|
+
}
|
|
79
|
+
function createPkce() {
|
|
80
|
+
const verifier = randomToken(32);
|
|
81
|
+
return {
|
|
82
|
+
state: randomToken(16),
|
|
83
|
+
verifier,
|
|
84
|
+
challenge: codeChallenge(verifier)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/http.ts
|
|
89
|
+
var ApiError = class extends Error {
|
|
90
|
+
status;
|
|
91
|
+
constructor(message, status) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = "ApiError";
|
|
94
|
+
this.status = status;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var VERSION = "0.1.0";
|
|
98
|
+
function buildRequest(opts) {
|
|
99
|
+
const url = new URL(opts.apiUrl.replace(/\/+$/, "") + opts.path);
|
|
100
|
+
if (opts.query) {
|
|
101
|
+
for (const [key, value] of Object.entries(opts.query)) {
|
|
102
|
+
if (value !== void 0 && value !== "") {
|
|
103
|
+
url.searchParams.set(key, String(value));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const headers = {
|
|
108
|
+
"User-Agent": `greybull-cli/${VERSION}`,
|
|
109
|
+
Accept: "application/json"
|
|
110
|
+
};
|
|
111
|
+
if (opts.apiKey) headers["X-API-Key"] = opts.apiKey;
|
|
112
|
+
let body;
|
|
113
|
+
if (opts.body !== void 0) {
|
|
114
|
+
body = JSON.stringify(opts.body);
|
|
115
|
+
headers["Content-Type"] = "application/json";
|
|
116
|
+
}
|
|
117
|
+
return { method: opts.method, url: url.toString(), headers, body };
|
|
118
|
+
}
|
|
119
|
+
async function performRequest(spec, apiKey) {
|
|
120
|
+
const headers = { ...spec.headers };
|
|
121
|
+
if (apiKey) headers["X-API-Key"] = apiKey;
|
|
122
|
+
let res;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(spec.url, {
|
|
125
|
+
method: spec.method,
|
|
126
|
+
headers,
|
|
127
|
+
body: spec.body,
|
|
128
|
+
signal: AbortSignal.timeout(3e4)
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
throw new ApiError(`Could not reach ${new URL(spec.url).origin}`, 0);
|
|
132
|
+
}
|
|
133
|
+
const text = await res.text();
|
|
134
|
+
let json;
|
|
135
|
+
try {
|
|
136
|
+
json = text ? JSON.parse(text) : {};
|
|
137
|
+
} catch {
|
|
138
|
+
json = {};
|
|
139
|
+
}
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw new ApiError(json?.error || res.statusText || "Request failed", res.status);
|
|
142
|
+
}
|
|
143
|
+
return json;
|
|
144
|
+
}
|
|
145
|
+
async function apiRequest(opts) {
|
|
146
|
+
const { apiKey, ...rest } = opts;
|
|
147
|
+
return performRequest(buildRequest(rest), apiKey);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/output.ts
|
|
151
|
+
function formatTable(headers, rows) {
|
|
152
|
+
if (rows.length === 0) return "(none)";
|
|
153
|
+
const widths = headers.map(
|
|
154
|
+
(h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
|
|
155
|
+
);
|
|
156
|
+
const line = (cells) => cells.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd();
|
|
157
|
+
return [line(headers), ...rows.map(line)].join("\n");
|
|
158
|
+
}
|
|
159
|
+
function fail(error, opts) {
|
|
160
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
if (error instanceof ApiError) {
|
|
162
|
+
if (error.status === 401) {
|
|
163
|
+
message += `
|
|
164
|
+
Run 'greybull login' to authenticate (or set GREYBULL_API_KEY).`;
|
|
165
|
+
} else if (error.status === 403) {
|
|
166
|
+
message += `
|
|
167
|
+
Your API key is missing a required scope \u2014 create one with DNS/domain permissions at ${opts.apiUrl}/api-settings.`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
process.stderr.write(`Error: ${message}
|
|
171
|
+
`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/commands/auth.ts
|
|
176
|
+
function persist(apiKey, ctx2) {
|
|
177
|
+
const config = { apiKey };
|
|
178
|
+
if (ctx2.apiUrl !== DEFAULT_API_URL) config.apiUrl = ctx2.apiUrl;
|
|
179
|
+
saveConfig(config);
|
|
180
|
+
}
|
|
181
|
+
async function validateAndSave(apiKey, ctx2) {
|
|
182
|
+
const profile = await apiRequest({
|
|
183
|
+
apiUrl: ctx2.apiUrl,
|
|
184
|
+
apiKey,
|
|
185
|
+
method: "GET",
|
|
186
|
+
path: "/api/v1/profile"
|
|
187
|
+
});
|
|
188
|
+
persist(apiKey, ctx2);
|
|
189
|
+
process.stdout.write(`Logged in as ${profile.data.email}
|
|
190
|
+
`);
|
|
191
|
+
}
|
|
192
|
+
async function loginWithKey(ctx2, providedKey) {
|
|
193
|
+
let apiKey = providedKey;
|
|
194
|
+
if (!apiKey) {
|
|
195
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
196
|
+
apiKey = (await rl.question("API key (ck_...): ")).trim();
|
|
197
|
+
rl.close();
|
|
198
|
+
}
|
|
199
|
+
if (!apiKey) fail(new Error("No API key provided"), ctx2);
|
|
200
|
+
try {
|
|
201
|
+
await validateAndSave(apiKey, ctx2);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
204
|
+
fail(new Error("That API key was rejected."), ctx2);
|
|
205
|
+
}
|
|
206
|
+
fail(err, ctx2);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function loginWithBrowser(ctx2) {
|
|
210
|
+
const pkce = createPkce();
|
|
211
|
+
const deviceName = os2.hostname();
|
|
212
|
+
const { code } = await new Promise((resolve, reject) => {
|
|
213
|
+
const server = http.createServer((req, res) => {
|
|
214
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
215
|
+
if (url.pathname !== "/callback") {
|
|
216
|
+
res.writeHead(404).end();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const returnedState = url.searchParams.get("state");
|
|
220
|
+
const returnedCode = url.searchParams.get("code");
|
|
221
|
+
const error = url.searchParams.get("error");
|
|
222
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
223
|
+
if (error || !returnedCode || returnedState !== pkce.state) {
|
|
224
|
+
res.end(page("Login failed", "You can close this tab and try again."));
|
|
225
|
+
server.close();
|
|
226
|
+
reject(new Error(error === "access_denied" ? "Authorization was declined." : "Login failed."));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
res.end(page("Logged in", "You can close this tab and return to your terminal."));
|
|
230
|
+
server.close();
|
|
231
|
+
resolve({ code: returnedCode });
|
|
232
|
+
});
|
|
233
|
+
server.on("error", reject);
|
|
234
|
+
server.listen(0, "127.0.0.1", () => {
|
|
235
|
+
const port = server.address().port;
|
|
236
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
237
|
+
const authorizeUrl = new URL("/cli/authorize", ctx2.apiUrl);
|
|
238
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
239
|
+
authorizeUrl.searchParams.set("state", pkce.state);
|
|
240
|
+
authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
241
|
+
authorizeUrl.searchParams.set("name", deviceName);
|
|
242
|
+
process.stdout.write("Opening your browser to authorize the CLI\u2026\n");
|
|
243
|
+
const opened = openBrowser(authorizeUrl.toString());
|
|
244
|
+
if (!opened) {
|
|
245
|
+
process.stdout.write(`Open this URL to continue:
|
|
246
|
+
${authorizeUrl.toString()}
|
|
247
|
+
`);
|
|
248
|
+
} else {
|
|
249
|
+
process.stdout.write(`If it doesn't open, visit:
|
|
250
|
+
${authorizeUrl.toString()}
|
|
251
|
+
`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
server.close();
|
|
256
|
+
reject(new Error("Timed out waiting for browser authorization."));
|
|
257
|
+
}, 12e4).unref();
|
|
258
|
+
}).catch((err) => {
|
|
259
|
+
fail(err, ctx2);
|
|
260
|
+
});
|
|
261
|
+
const result = await apiRequest({
|
|
262
|
+
apiUrl: ctx2.apiUrl,
|
|
263
|
+
method: "POST",
|
|
264
|
+
path: "/api/cli/token",
|
|
265
|
+
body: { code, code_verifier: pkce.verifier }
|
|
266
|
+
}).catch((err) => fail(err, ctx2));
|
|
267
|
+
persist(result.apiKey, ctx2);
|
|
268
|
+
process.stdout.write(`Logged in as ${result.email}
|
|
269
|
+
`);
|
|
270
|
+
}
|
|
271
|
+
function logout() {
|
|
272
|
+
if (!resolveApiKey()) {
|
|
273
|
+
process.stdout.write("Not logged in.\n");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
clearApiKey();
|
|
277
|
+
process.stdout.write(`Logged out. Removed the stored key from ${configPath()}.
|
|
278
|
+
`);
|
|
279
|
+
}
|
|
280
|
+
async function whoami(ctx2) {
|
|
281
|
+
if (!ctx2.apiKey) fail(new ApiError("Not logged in", 401), ctx2);
|
|
282
|
+
const profile = await apiRequest({
|
|
283
|
+
apiUrl: ctx2.apiUrl,
|
|
284
|
+
apiKey: ctx2.apiKey,
|
|
285
|
+
method: "GET",
|
|
286
|
+
path: "/api/v1/profile"
|
|
287
|
+
}).catch((err) => fail(err, ctx2));
|
|
288
|
+
if (ctx2.json) {
|
|
289
|
+
process.stdout.write(JSON.stringify(profile.data, null, 2) + "\n");
|
|
290
|
+
} else {
|
|
291
|
+
const name = profile.data.name ? ` (${profile.data.name})` : "";
|
|
292
|
+
process.stdout.write(`${profile.data.email}${name}
|
|
293
|
+
`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function page(title, body) {
|
|
297
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
298
|
+
<body style="font-family:system-ui;display:flex;height:100vh;align-items:center;justify-content:center;margin:0">
|
|
299
|
+
<div style="text-align:center"><h1 style="font-size:1.25rem">${title}</h1><p style="color:#666">${body}</p></div>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/commands/dns.ts
|
|
303
|
+
var RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
|
|
304
|
+
function buildListRecords(apiUrl, zone, opts) {
|
|
305
|
+
return buildRequest({
|
|
306
|
+
apiUrl,
|
|
307
|
+
method: "GET",
|
|
308
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`,
|
|
309
|
+
query: { name: opts.name, type: opts.type }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function buildAddRecord(apiUrl, zone, type, name, content, opts) {
|
|
313
|
+
return buildRequest({
|
|
314
|
+
apiUrl,
|
|
315
|
+
method: "POST",
|
|
316
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`,
|
|
317
|
+
body: {
|
|
318
|
+
name,
|
|
319
|
+
type: type.toUpperCase(),
|
|
320
|
+
content,
|
|
321
|
+
...opts.ttl !== void 0 ? { ttl: opts.ttl } : {},
|
|
322
|
+
...opts.priority !== void 0 ? { priority: opts.priority } : {}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function buildUpdateRecord(apiUrl, zone, original, updated) {
|
|
327
|
+
return buildRequest({
|
|
328
|
+
apiUrl,
|
|
329
|
+
method: "PUT",
|
|
330
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`,
|
|
331
|
+
body: {
|
|
332
|
+
original: {
|
|
333
|
+
name: original.name,
|
|
334
|
+
type: original.type,
|
|
335
|
+
content: original.content,
|
|
336
|
+
...original.priority !== void 0 ? { priority: original.priority } : {}
|
|
337
|
+
},
|
|
338
|
+
updated
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function buildDeleteRecord(apiUrl, zone, type, name, content, opts) {
|
|
343
|
+
return buildRequest({
|
|
344
|
+
apiUrl,
|
|
345
|
+
method: "DELETE",
|
|
346
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`,
|
|
347
|
+
query: { name, type: type.toUpperCase(), content, priority: opts.priority }
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function assertType(type, ctx2) {
|
|
351
|
+
const upper = type.toUpperCase();
|
|
352
|
+
if (!RECORD_TYPES.includes(upper)) {
|
|
353
|
+
fail(new Error(`Invalid record type '${type}'. Valid types: ${RECORD_TYPES.join(", ")}`), ctx2);
|
|
354
|
+
}
|
|
355
|
+
return upper;
|
|
356
|
+
}
|
|
357
|
+
async function zonesCmd(ctx2) {
|
|
358
|
+
const res = await apiRequest({ apiUrl: ctx2.apiUrl, apiKey: ctx2.apiKey, method: "GET", path: "/api/v1/dns/zones" }).catch((err) => fail(err, ctx2));
|
|
359
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
360
|
+
const rows = res.data.map((z) => [z.name, z.kind, String(z.serial)]);
|
|
361
|
+
process.stdout.write(formatTable(["ZONE", "KIND", "SERIAL"], rows) + "\n");
|
|
362
|
+
}
|
|
363
|
+
async function listCmd(ctx2, zone, opts) {
|
|
364
|
+
const type = opts.type ? assertType(opts.type, ctx2) : void 0;
|
|
365
|
+
const spec = buildListRecords(ctx2.apiUrl, zone, { type, name: opts.name });
|
|
366
|
+
const res = await runSpec(spec, ctx2);
|
|
367
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
368
|
+
const rows = res.data.map((r) => [
|
|
369
|
+
r.name,
|
|
370
|
+
r.type,
|
|
371
|
+
r.content,
|
|
372
|
+
String(r.ttl),
|
|
373
|
+
r.priority !== void 0 ? String(r.priority) : ""
|
|
374
|
+
]);
|
|
375
|
+
process.stdout.write(formatTable(["NAME", "TYPE", "CONTENT", "TTL", "PRIO"], rows) + "\n");
|
|
376
|
+
}
|
|
377
|
+
async function addCmd(ctx2, zone, type, name, content, opts) {
|
|
378
|
+
const upperType = assertType(type, ctx2);
|
|
379
|
+
if (upperType === "TXT" && !content.startsWith('"')) {
|
|
380
|
+
process.stderr.write(`Note: TXT content usually needs quotes, e.g. '"v=spf1 ..."'. Sending as given.
|
|
381
|
+
`);
|
|
382
|
+
}
|
|
383
|
+
const spec = buildAddRecord(ctx2.apiUrl, zone, upperType, name, content, {
|
|
384
|
+
ttl: opts.ttl ? parseInt(opts.ttl) : void 0,
|
|
385
|
+
priority: opts.priority ? parseInt(opts.priority) : void 0
|
|
386
|
+
});
|
|
387
|
+
const res = await runSpec(spec, ctx2);
|
|
388
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
389
|
+
process.stdout.write(`Added ${upperType} ${name} \u2192 ${content}
|
|
390
|
+
`);
|
|
391
|
+
}
|
|
392
|
+
async function updateCmd(ctx2, zone, type, name, oldContent, newContent, opts) {
|
|
393
|
+
const upperType = assertType(type, ctx2);
|
|
394
|
+
const priority = opts.priority ? parseInt(opts.priority) : void 0;
|
|
395
|
+
const listRes = await runSpec(buildListRecords(ctx2.apiUrl, zone, { type: upperType, name }), ctx2);
|
|
396
|
+
const match = listRes.data.find(
|
|
397
|
+
(r) => r.content === oldContent && (priority === void 0 || r.priority === priority)
|
|
398
|
+
);
|
|
399
|
+
if (!match) {
|
|
400
|
+
fail(new Error(`No ${upperType} record '${name}' with content ${oldContent} found. Run 'greybull dns list ${zone}'.`), ctx2);
|
|
401
|
+
}
|
|
402
|
+
const spec = buildUpdateRecord(ctx2.apiUrl, zone, match, {
|
|
403
|
+
name: opts.newName ?? name,
|
|
404
|
+
type: upperType,
|
|
405
|
+
content: newContent,
|
|
406
|
+
ttl: opts.ttl ? parseInt(opts.ttl) : match.ttl,
|
|
407
|
+
...opts.priority ? { priority } : match.priority !== void 0 ? { priority: match.priority } : {}
|
|
408
|
+
});
|
|
409
|
+
const res = await runSpec(spec, ctx2);
|
|
410
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
411
|
+
process.stdout.write(`Updated ${upperType} ${name}
|
|
412
|
+
`);
|
|
413
|
+
}
|
|
414
|
+
async function deleteCmd(ctx2, zone, type, name, content, opts) {
|
|
415
|
+
const upperType = assertType(type, ctx2);
|
|
416
|
+
const spec = buildDeleteRecord(ctx2.apiUrl, zone, upperType, name, content, {
|
|
417
|
+
priority: opts.priority ? parseInt(opts.priority) : void 0
|
|
418
|
+
});
|
|
419
|
+
await runSpec(spec, ctx2);
|
|
420
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify({ deleted: true }, null, 2) + "\n");
|
|
421
|
+
process.stdout.write(`Deleted ${upperType} ${name} \u2192 ${content}
|
|
422
|
+
`);
|
|
423
|
+
}
|
|
424
|
+
function runSpec(spec, ctx2) {
|
|
425
|
+
return performRequest(spec, ctx2.apiKey).catch((err) => fail(err, ctx2));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/commands/domains.ts
|
|
429
|
+
async function domainsListCmd(ctx2) {
|
|
430
|
+
const res = await apiRequest({
|
|
431
|
+
apiUrl: ctx2.apiUrl,
|
|
432
|
+
apiKey: ctx2.apiKey,
|
|
433
|
+
method: "GET",
|
|
434
|
+
path: "/api/v1/domains"
|
|
435
|
+
}).catch((err) => fail(err, ctx2));
|
|
436
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
437
|
+
const rows = res.data.map((d) => [
|
|
438
|
+
d.name,
|
|
439
|
+
d.status ?? "",
|
|
440
|
+
d.registrar ?? "",
|
|
441
|
+
d.expiresAt ? new Date(d.expiresAt).toISOString().slice(0, 10) : "",
|
|
442
|
+
d.autoRenew ? "yes" : "no"
|
|
443
|
+
]);
|
|
444
|
+
process.stdout.write(
|
|
445
|
+
formatTable(["DOMAIN", "STATUS", "REGISTRAR", "EXPIRES", "AUTO-RENEW"], rows) + "\n"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
async function domainsCheckCmd(ctx2, query, opts) {
|
|
449
|
+
const res = await apiRequest({
|
|
450
|
+
apiUrl: ctx2.apiUrl,
|
|
451
|
+
apiKey: ctx2.apiKey,
|
|
452
|
+
method: "GET",
|
|
453
|
+
path: "/api/v1/domains/search",
|
|
454
|
+
query: { q: query, tlds: opts.tlds }
|
|
455
|
+
}).catch((err) => fail(err, ctx2));
|
|
456
|
+
if (ctx2.json) return void process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
|
|
457
|
+
const rows = res.data.map((r) => [
|
|
458
|
+
r.domain,
|
|
459
|
+
r.available ? "available" : "taken",
|
|
460
|
+
r.available && r.price !== void 0 ? `${r.price} ${r.currency ?? ""}`.trim() : ""
|
|
461
|
+
]);
|
|
462
|
+
process.stdout.write(formatTable(["DOMAIN", "AVAILABLE", "PRICE"], rows) + "\n");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/index.ts
|
|
466
|
+
var program = new Command();
|
|
467
|
+
program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.1.0").option("--json", "output raw JSON", false).option("--api-url <url>", "portal base URL (or set GREYBULL_API_URL)");
|
|
468
|
+
function ctx(requireAuth = true) {
|
|
469
|
+
const opts = program.opts();
|
|
470
|
+
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
471
|
+
const apiKey = resolveApiKey() ?? void 0;
|
|
472
|
+
if (requireAuth && !apiKey) {
|
|
473
|
+
fail(new Error("Not logged in. Run 'greybull login' first."), { apiUrl });
|
|
474
|
+
}
|
|
475
|
+
return { apiUrl, apiKey, json: opts.json };
|
|
476
|
+
}
|
|
477
|
+
function loginCtx() {
|
|
478
|
+
const opts = program.opts();
|
|
479
|
+
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
480
|
+
return { apiUrl, apiUrlExplicit: Boolean(opts.apiUrl || process.env.GREYBULL_API_URL) };
|
|
481
|
+
}
|
|
482
|
+
program.command("login").description("Log in via the browser (or --with-key to paste an API key)").option("--with-key", "authenticate by pasting an API key instead of the browser", false).option("--api-key <key>", "provide the API key non-interactively (implies --with-key)").action(async (opts) => {
|
|
483
|
+
if (opts.withKey || opts.apiKey) {
|
|
484
|
+
await loginWithKey(loginCtx(), opts.apiKey);
|
|
485
|
+
} else {
|
|
486
|
+
await loginWithBrowser(loginCtx());
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
program.command("logout").description("Remove the stored API key").action(() => logout());
|
|
490
|
+
program.command("whoami").description("Show the authenticated account").action(async () => {
|
|
491
|
+
await whoami(ctx());
|
|
492
|
+
});
|
|
493
|
+
var dns = program.command("dns").description("Manage DNS records");
|
|
494
|
+
dns.command("zones").description("List your DNS zones").action(async () => {
|
|
495
|
+
await zonesCmd(ctx());
|
|
496
|
+
});
|
|
497
|
+
dns.command("list <zone>").description("List records in a zone").option("--type <type>", "filter by record type").option("--name <name>", "filter by record name").action(async (zone, opts) => {
|
|
498
|
+
await listCmd(ctx(), zone, opts);
|
|
499
|
+
});
|
|
500
|
+
dns.command("add <zone> <type> <name> <content>").description("Add a record (siblings with the same name+type are preserved)").option("--ttl <seconds>", "TTL in seconds (default 3600)").option("--priority <n>", "priority (MX/SRV)").action(async (zone, type, name, content, opts) => {
|
|
501
|
+
await addCmd(ctx(), zone, type, name, content, opts);
|
|
502
|
+
});
|
|
503
|
+
dns.command("update <zone> <type> <name> <old-content> <new-content>").description("Change one record; siblings are preserved, TTL kept unless --ttl given").option("--ttl <seconds>", "new TTL in seconds").option("--priority <n>", "match/set priority (MX/SRV)").option("--new-name <name>", "rename the record").action(async (zone, type, name, oldContent, newContent, opts) => {
|
|
504
|
+
await updateCmd(ctx(), zone, type, name, oldContent, newContent, opts);
|
|
505
|
+
});
|
|
506
|
+
dns.command("delete <zone> <type> <name> <content>").description("Delete one specific record (content required \u2014 sibling-safe)").option("--priority <n>", "priority (MX/SRV)").action(async (zone, type, name, content, opts) => {
|
|
507
|
+
await deleteCmd(ctx(), zone, type, name, content, opts);
|
|
508
|
+
});
|
|
509
|
+
var domains = program.command("domains").description("Manage domains");
|
|
510
|
+
domains.command("list").description("List your registered domains").action(async () => {
|
|
511
|
+
await domainsListCmd(ctx());
|
|
512
|
+
});
|
|
513
|
+
domains.command("check <query>").description("Check domain availability").option("--tlds <list>", "comma-separated TLDs for a bare label, e.g. nl,com").action(async (query, opts) => {
|
|
514
|
+
await domainsCheckCmd(ctx(), query, opts);
|
|
515
|
+
});
|
|
516
|
+
program.parseAsync().catch((err) => {
|
|
517
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
518
|
+
`);
|
|
519
|
+
process.exit(1);
|
|
520
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "greybull",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line tool for the Greybull portal — manage DNS records and domains",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"greybull": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+ssh://git@gitlab.bouwhuis.net/greybull/greybull-dash.git",
|
|
20
|
+
"directory": "cli"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"greybull",
|
|
24
|
+
"dns",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"dev": "tsup --watch",
|
|
31
|
+
"prepublishOnly": "npm run build && npm test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^13.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.19.0",
|
|
38
|
+
"tsup": "^8.3.0",
|
|
39
|
+
"typescript": "^5.7.0",
|
|
40
|
+
"vitest": "^4.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|