@sealant/mend 0.8.0 → 0.10.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 +17 -9
- package/dist/dashboard.js +397 -282
- package/dist/login.js +240 -0
- package/dist/main.js +184 -134
- package/dist/review.js +172 -87
- package/dist/shared.js +8 -0
- package/dist/tui-theme.js +33 -22
- package/dist/workbench-events.js +71 -0
- package/package.json +1 -1
package/dist/login.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import { groupCode } from "./pair.js";
|
|
4
|
+
/**
|
|
5
|
+
* The opened request as the server's JSON, checked field by field before the
|
|
6
|
+
* CLI acts on it — a captive portal or a non-Mend server answering 200 must
|
|
7
|
+
* read as "not a Mend server", never as a crash. Null follows the CLI's
|
|
8
|
+
* absence idiom (chooseUrl, minutesUntil); the one caller turns it into the
|
|
9
|
+
* exit message.
|
|
10
|
+
*/
|
|
11
|
+
const parseCliAuthStart = (json) => {
|
|
12
|
+
if (typeof json !== "object" || json === null)
|
|
13
|
+
return null;
|
|
14
|
+
if (!("deviceCode" in json &&
|
|
15
|
+
"code" in json &&
|
|
16
|
+
"verifyPath" in json &&
|
|
17
|
+
"expiresAt" in json &&
|
|
18
|
+
"intervalSeconds" in json)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const { deviceCode, code, verifyPath, expiresAt, intervalSeconds } = json;
|
|
22
|
+
if (typeof deviceCode !== "string" ||
|
|
23
|
+
typeof code !== "string" ||
|
|
24
|
+
typeof verifyPath !== "string" ||
|
|
25
|
+
typeof expiresAt !== "string" ||
|
|
26
|
+
typeof intervalSeconds !== "number") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { deviceCode, code, verifyPath, expiresAt, intervalSeconds };
|
|
30
|
+
};
|
|
31
|
+
/** A poll answer's JSON, checked the same way; anything off-shape answers null. */
|
|
32
|
+
const parseCliAuthPoll = (json) => {
|
|
33
|
+
if (typeof json !== "object" || json === null || !("status" in json))
|
|
34
|
+
return null;
|
|
35
|
+
if (json.status === "pending")
|
|
36
|
+
return { status: "pending" };
|
|
37
|
+
if (json.status !== "approved")
|
|
38
|
+
return null;
|
|
39
|
+
if (!("token" in json && "user" in json && "device" in json))
|
|
40
|
+
return null;
|
|
41
|
+
const { token, user, device } = json;
|
|
42
|
+
if (typeof token !== "string")
|
|
43
|
+
return null;
|
|
44
|
+
if (typeof user !== "object" || user === null)
|
|
45
|
+
return null;
|
|
46
|
+
if (!("id" in user && "name" in user && "email" in user))
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof user.id !== "string" ||
|
|
49
|
+
typeof user.name !== "string" ||
|
|
50
|
+
typeof user.email !== "string") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (typeof device !== "object" || device === null)
|
|
54
|
+
return null;
|
|
55
|
+
if (!("id" in device && "name" in device))
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof device.id !== "string" || typeof device.name !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
status: "approved",
|
|
61
|
+
token,
|
|
62
|
+
user: { id: user.id, name: user.name, email: user.email },
|
|
63
|
+
device: { id: device.id, name: device.name },
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const paint = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}[0m` : text;
|
|
67
|
+
const dim = paint("2");
|
|
68
|
+
const green = paint("32");
|
|
69
|
+
const cobalt = paint("34");
|
|
70
|
+
const say = (line) => process.stdout.write(`${line}\n`);
|
|
71
|
+
const fail = (message) => {
|
|
72
|
+
process.stderr.write(`mend: ${message}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
};
|
|
75
|
+
const takeFlagValue = (args, flag) => {
|
|
76
|
+
const at = args.indexOf(flag);
|
|
77
|
+
return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The server URL as a human types it: a bare `host:port` gets http://, a
|
|
81
|
+
* trailing slash goes, anything the URL parser rejects answers null so the
|
|
82
|
+
* caller can say so. The path is kept — an instance can live under one.
|
|
83
|
+
*/
|
|
84
|
+
export const normalizeServerUrl = (input) => {
|
|
85
|
+
const trimmed = input.trim();
|
|
86
|
+
if (trimmed === "")
|
|
87
|
+
return null;
|
|
88
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
89
|
+
let url;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(withScheme);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
97
|
+
return null;
|
|
98
|
+
return `${url.origin}${url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "")}`;
|
|
99
|
+
};
|
|
100
|
+
/** The page a human approves on — the verify path resolved against the URL the CLI dialed. */
|
|
101
|
+
export const authorizeUrl = (base, verifyPath) => `${base}${verifyPath}`;
|
|
102
|
+
/** How the browser opens on this platform; null means print the URL and let the human click. */
|
|
103
|
+
export const browserCommand = (platform) => {
|
|
104
|
+
if (platform === "darwin")
|
|
105
|
+
return { command: "open", args: [] };
|
|
106
|
+
if (platform === "linux")
|
|
107
|
+
return { command: "xdg-open", args: [] };
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
/** Polling cadence: what the server asked for, held to a sane band. */
|
|
111
|
+
export const pollDelayMs = (intervalSeconds) => Math.min(Math.max(Math.round(intervalSeconds), 1), 10) * 1000;
|
|
112
|
+
/** When to stop polling: the request's own expiry, or ten minutes for an unreadable date. */
|
|
113
|
+
export const pollDeadline = (expiresAt, now = Date.now()) => {
|
|
114
|
+
const at = Date.parse(expiresAt);
|
|
115
|
+
return Number.isNaN(at) ? now + 10 * 60_000 : at;
|
|
116
|
+
};
|
|
117
|
+
/** Fire-and-forget: a browser that fails to open must never take the login down. */
|
|
118
|
+
const openBrowser = (url) => {
|
|
119
|
+
const opener = browserCommand(process.platform);
|
|
120
|
+
if (opener === null || process.stdout.isTTY !== true)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const child = spawn(opener.command, [...opener.args, url], {
|
|
124
|
+
stdio: "ignore",
|
|
125
|
+
detached: true,
|
|
126
|
+
});
|
|
127
|
+
child.on("error", () => undefined);
|
|
128
|
+
child.unref();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// The URL is already printed; clicking it is the fallback.
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/** One unauthenticated POST to the authorize surface; anything but 2xx surfaces as a status. */
|
|
135
|
+
const post = async (url, body) => {
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetch(url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return fail(`cannot reach the Mend server at ${new URL(url).origin} — is it running?`);
|
|
146
|
+
}
|
|
147
|
+
const text = await response.text();
|
|
148
|
+
let json = null;
|
|
149
|
+
try {
|
|
150
|
+
json = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// A non-JSON body only matters for the happy path, which always is JSON.
|
|
154
|
+
}
|
|
155
|
+
return { status: response.status, json };
|
|
156
|
+
};
|
|
157
|
+
const retryAfterSecondsOf = (json) => {
|
|
158
|
+
if (typeof json === "object" && json !== null && "retryAfterSeconds" in json) {
|
|
159
|
+
const seconds = Number(json.retryAfterSeconds);
|
|
160
|
+
if (Number.isFinite(seconds) && seconds > 0)
|
|
161
|
+
return seconds;
|
|
162
|
+
}
|
|
163
|
+
return 5;
|
|
164
|
+
};
|
|
165
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
/**
|
|
167
|
+
* Where this login points: `--url` wins, then whatever is already configured
|
|
168
|
+
* (MEND_URL or the config file) — a set URL is never asked about again;
|
|
169
|
+
* `--url` is how you point somewhere else. Only a fresh machine prompts, and
|
|
170
|
+
* only on a terminal; a piped stdin takes the default silently.
|
|
171
|
+
*/
|
|
172
|
+
const resolveServerUrl = async (args, deps) => {
|
|
173
|
+
const given = takeFlagValue(args, "--url") ?? deps.configuredUrl;
|
|
174
|
+
if (given !== null) {
|
|
175
|
+
return normalizeServerUrl(given) ?? fail(`"${given}" is not a URL the CLI can dial`);
|
|
176
|
+
}
|
|
177
|
+
if (process.stdin.isTTY !== true) {
|
|
178
|
+
return (normalizeServerUrl(deps.defaultUrl) ??
|
|
179
|
+
fail(`"${deps.defaultUrl}" is not a URL the CLI can dial`));
|
|
180
|
+
}
|
|
181
|
+
const readline = await import("node:readline/promises");
|
|
182
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
183
|
+
const typed = (await rl.question(`mend server url ${dim(`[${deps.defaultUrl}]`)}: `)).trim();
|
|
184
|
+
rl.close();
|
|
185
|
+
const chosen = typed === "" ? deps.defaultUrl : typed;
|
|
186
|
+
return normalizeServerUrl(chosen) ?? fail(`"${chosen}" is not a URL the CLI can dial`);
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* `mend login [--url <server>]` — open, show, wait, save. The token lands in
|
|
190
|
+
* the CLI config (0600) together with the device id, so `mend logout` can
|
|
191
|
+
* revoke the token server-side instead of merely forgetting it.
|
|
192
|
+
*/
|
|
193
|
+
export const loginCommand = async (args, deps) => {
|
|
194
|
+
const base = await resolveServerUrl(args, deps);
|
|
195
|
+
const started = await post(`${base}/api/cli/auth`, { name: os.hostname() });
|
|
196
|
+
if (started.status === 429) {
|
|
197
|
+
return fail(`the server asked for a pause — try again in ${retryAfterSecondsOf(started.json)}s`);
|
|
198
|
+
}
|
|
199
|
+
if (started.status < 200 || started.status >= 300) {
|
|
200
|
+
return fail(`the server at ${base} refused to open an authorize request (${started.status})`);
|
|
201
|
+
}
|
|
202
|
+
const opened = parseCliAuthStart(started.json) ??
|
|
203
|
+
fail(`the server at ${base} did not answer like a Mend server — is that the right URL?`);
|
|
204
|
+
const url = authorizeUrl(base, opened.verifyPath);
|
|
205
|
+
say(`${green("✓")} authorize request open at ${base}`);
|
|
206
|
+
say(` ${dim("code")} ${groupCode(opened.code)} ${dim("· approve only if the browser shows the same code")}`);
|
|
207
|
+
say(` ${dim("browser")} ${cobalt(url)}`);
|
|
208
|
+
say("");
|
|
209
|
+
say(dim(" waiting for approval… Ctrl-C stops; nothing is granted until someone approves"));
|
|
210
|
+
openBrowser(url);
|
|
211
|
+
const deadline = pollDeadline(opened.expiresAt);
|
|
212
|
+
const delay = pollDelayMs(opened.intervalSeconds);
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
await sleep(delay);
|
|
215
|
+
const poll = await post(`${base}/api/cli/auth/token`, { deviceCode: opened.deviceCode });
|
|
216
|
+
if (poll.status === 429) {
|
|
217
|
+
await sleep(retryAfterSecondsOf(poll.json) * 1000);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (poll.status === 403)
|
|
221
|
+
return fail("denied in the browser; nothing was granted");
|
|
222
|
+
if (poll.status === 404 || poll.status === 410) {
|
|
223
|
+
return fail("the authorize request is no longer open — run mend login again");
|
|
224
|
+
}
|
|
225
|
+
if (poll.status < 200 || poll.status >= 300) {
|
|
226
|
+
return fail(`the server answered ${poll.status} while waiting — run mend login again`);
|
|
227
|
+
}
|
|
228
|
+
const result = parseCliAuthPoll(poll.json);
|
|
229
|
+
if (result === null) {
|
|
230
|
+
return fail("the server's answer stopped making sense while waiting — run mend login again");
|
|
231
|
+
}
|
|
232
|
+
if (result.status === "pending")
|
|
233
|
+
continue;
|
|
234
|
+
deps.save({ url: base, token: result.token, deviceId: result.device.id });
|
|
235
|
+
say(`${green("✓")} signed in as ${result.user.email}`);
|
|
236
|
+
say(` ${dim("this terminal is the device")} ${result.device.name} ${dim("· revoke it any time under Settings → Devices")}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
return fail("the authorize request expired before anyone approved — run mend login again");
|
|
240
|
+
};
|