xovris 0.0.0-stage → 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/bin/xovris.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // The published package runs the bundled dist/cli.js; inside the repository the TypeScript source runs directly
3
+ // (Node.js 22.18+ strips types).
4
+ import { existsSync } from 'node:fs';
5
+
6
+ const dist = new URL('../dist/cli.js', import.meta.url);
7
+ await import(existsSync(dist) ? dist.href : new URL('../src/cli.ts', import.meta.url).href);
@@ -0,0 +1,480 @@
1
+ import { chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
5
+ import * as p from "@clack/prompts";
6
+
7
+ //#region src/version.ts
8
+ const CLI_VERSION = (() => {
9
+ try {
10
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
11
+ } catch {
12
+ return "0.0.0";
13
+ }
14
+ })();
15
+ const CLI_AGENT = `xovris-cli/${CLI_VERSION}`;
16
+
17
+ //#endregion
18
+ //#region src/api.ts
19
+ var ApiFailure = class extends Error {
20
+ problem;
21
+ network;
22
+ constructor(problem, network) {
23
+ super(problem ? `${problem.title}. ${problem.next}` : "No answer from Xovris.");
24
+ this.problem = problem;
25
+ this.network = network;
26
+ }
27
+ };
28
+ var Api = class {
29
+ token = null;
30
+ base;
31
+ sys;
32
+ constructor(base, sys) {
33
+ this.base = base;
34
+ this.sys = sys;
35
+ }
36
+ /** G57.72: any request to the public API for `xovris api`, answered as it came - the bearer added, never printed. */
37
+ raw(method, path, body, headers) {
38
+ return this.sys.fetch(`${this.base}/v1${path}`, {
39
+ method,
40
+ headers: {
41
+ accept: "application/json",
42
+ "user-agent": CLI_AGENT,
43
+ ...headers,
44
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
45
+ ...this.token ? { authorization: `Bearer ${this.token}` } : {}
46
+ },
47
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
48
+ });
49
+ }
50
+ async call(method, path, body, form = false) {
51
+ let res;
52
+ try {
53
+ res = await this.sys.fetch(`${this.base}/v1${path}`, {
54
+ method,
55
+ headers: {
56
+ accept: "application/json",
57
+ "user-agent": CLI_AGENT,
58
+ ...body !== void 0 ? { "content-type": form ? "application/x-www-form-urlencoded" : "application/json" } : {},
59
+ ...this.token ? { authorization: `Bearer ${this.token}` } : {}
60
+ },
61
+ ...body !== void 0 ? { body: form ? new URLSearchParams(body).toString() : JSON.stringify(body) } : {}
62
+ });
63
+ } catch {
64
+ throw new ApiFailure(null, true);
65
+ }
66
+ let data = null;
67
+ try {
68
+ data = await res.json();
69
+ } catch {
70
+ data = null;
71
+ }
72
+ return {
73
+ status: res.status,
74
+ data
75
+ };
76
+ }
77
+ async ok(method, path, body) {
78
+ const r = await this.call(method, path, body);
79
+ if (r.status >= 200 && r.status < 300) return r.data;
80
+ const d = r.data ?? {};
81
+ throw new ApiFailure({
82
+ status: r.status,
83
+ title: d.title ?? d.error?.message ?? `Request failed (${r.status})`,
84
+ detail: d.detail ?? "",
85
+ next: d.next ?? d.error?.nextStep ?? "Try again in a moment.",
86
+ ...typeof d.type === "string" ? { type: d.type } : {}
87
+ }, false);
88
+ }
89
+ deviceCode(clientName) {
90
+ return this.ok("POST", "/device/code", {
91
+ client_id: "xovris-cli",
92
+ client_name: clientName
93
+ });
94
+ }
95
+ async deviceToken(deviceCode) {
96
+ return this.call("POST", "/device/token", {
97
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
98
+ device_code: deviceCode,
99
+ client_id: "xovris-cli"
100
+ }, true);
101
+ }
102
+ startRun(project, platform, machine) {
103
+ return this.ok("POST", "/install-runs", {
104
+ project,
105
+ platform,
106
+ machine
107
+ });
108
+ }
109
+ /** AUT-16: the person's counted effort rides with each stage (null when no person is at it - never sent as a zero). */
110
+ stage(runId, stage, effort = null) {
111
+ return this.call("POST", `/install-runs/${encodeURIComponent(runId)}/stage`, effort ? {
112
+ stage,
113
+ effort
114
+ } : { stage });
115
+ }
116
+ verify(connectionId) {
117
+ return this.ok("GET", `/connections/${encodeURIComponent(connectionId)}/verify`);
118
+ }
119
+ /** D01.02.03: open (or return) the setup pull request. A refusal (412 consent, 409, 422) is an answer, not a throw. */
120
+ openPullRequest(connectionId, repository, path) {
121
+ return this.call("POST", `/connections/${encodeURIComponent(connectionId)}/github/pull-request`, {
122
+ repository,
123
+ path
124
+ });
125
+ }
126
+ remove(connectionId) {
127
+ return this.ok("POST", `/connections/${encodeURIComponent(connectionId)}/remove`);
128
+ }
129
+ };
130
+
131
+ //#endregion
132
+ //#region src/ui.ts
133
+ var Cancelled = class extends Error {
134
+ reason;
135
+ constructor(reason) {
136
+ super(reason);
137
+ this.reason = reason;
138
+ }
139
+ };
140
+ function clackUi(secrets, assumeYes, effort) {
141
+ const ask = (f) => effort ? effort.answer(f) : f();
142
+ const r = (s) => secrets.redact(s);
143
+ const cancelled = (v) => {
144
+ if (p.isCancel(v)) throw new Cancelled("Stopped. Nothing was changed after this point.");
145
+ };
146
+ return {
147
+ intro: (t) => p.intro(r(t)),
148
+ outro: (t) => p.outro(r(t)),
149
+ info: (t) => p.log.info(r(t)),
150
+ success: (t) => p.log.success(r(t)),
151
+ warn: (t) => p.log.warn(r(t)),
152
+ error: (t) => p.log.error(r(t)),
153
+ note: (b, t) => p.note(r(b), t ? r(t) : void 0),
154
+ async confirm(message) {
155
+ if (assumeYes) {
156
+ p.log.step(`${r(message)} yes (--yes)`);
157
+ return true;
158
+ }
159
+ const v = await ask(() => p.confirm({ message: r(message) }));
160
+ cancelled(v);
161
+ return v === true;
162
+ },
163
+ async select(message, options) {
164
+ const v = await ask(() => p.select({
165
+ message: r(message),
166
+ options: options.map((o) => ({
167
+ value: o.value,
168
+ label: r(o.label),
169
+ ...o.hint ? { hint: r(o.hint) } : {}
170
+ }))
171
+ }));
172
+ cancelled(v);
173
+ return v;
174
+ },
175
+ async task(start, work, done) {
176
+ const s = p.spinner();
177
+ s.start(r(start));
178
+ try {
179
+ const v = await work();
180
+ s.stop(r(done(v)));
181
+ return v;
182
+ } catch (e) {
183
+ s.stop(r(start), 1);
184
+ throw e;
185
+ }
186
+ }
187
+ };
188
+ }
189
+
190
+ //#endregion
191
+ //#region src/signin.ts
192
+ /** How many fresh codes the terminal asks for by itself before it stops waiting (each lasts the server's ten minutes). */
193
+ const RENEWALS = 3;
194
+ async function signIn(api, ui, sys, effort) {
195
+ const begin = async (again) => {
196
+ const start = await api.deviceCode(`Xovris command line on ${sys.hostname()}`);
197
+ const opened = await sys.openUrl(start.verification_uri_complete);
198
+ ui.note(`${again ? "The last code expired, so Xovris made a new one. " : ""}${opened ? "Your browser opened to approve this sign-in." : `Open ${start.verification_uri_complete} to approve this sign-in.`}\nCheck the browser shows the same code: ${start.user_code}\nYour data is never used to train our AI.`, "Sign in");
199
+ return start;
200
+ };
201
+ const approve = (f) => effort ? effort.answer(f) : f();
202
+ return ui.task("Waiting for you to approve in the browser", () => approve(async () => {
203
+ for (let round = 0; round <= RENEWALS; round += 1) {
204
+ const start = await begin(round > 0);
205
+ let interval = start.interval;
206
+ const deadline = sys.now() + start.expires_in * 1e3;
207
+ let expired = false;
208
+ while (!expired && sys.now() < deadline) {
209
+ await sys.sleep(interval * 1e3);
210
+ const r = await api.deviceToken(start.device_code);
211
+ if (r.status === 200 && r.data.access_token && r.data.workspace) {
212
+ api.token = r.data.access_token;
213
+ return { workspace: r.data.workspace };
214
+ }
215
+ switch (r.data.error) {
216
+ case "authorization_pending": continue;
217
+ case "slow_down":
218
+ interval += 5;
219
+ continue;
220
+ case "access_denied": throw new Cancelled("Sign-in was declined in the browser. Nothing was changed.");
221
+ case "expired_token":
222
+ expired = true;
223
+ continue;
224
+ default: throw new Cancelled(`Sign-in failed: ${r.data.error_description ?? r.data.error ?? `status ${r.status}`}. Nothing was changed.`);
225
+ }
226
+ }
227
+ }
228
+ throw new Cancelled(`No one approved the sign-in after ${RENEWALS + 1} codes. Run the command again when you are ready. Nothing was changed.`);
229
+ }), (v) => `Signed in to ${v.workspace.slug}`);
230
+ }
231
+
232
+ //#endregion
233
+ //#region ../checks/src/atomic-write.ts
234
+ let seq = 0;
235
+ const asidePath = (file) => `${file}.${process.pid}.${Date.now().toString(36)}.${(seq++).toString(36)}.tmp`;
236
+ /** The same, asynchronously. */
237
+ async function writeFileAtomic(file, data, o = {}) {
238
+ await mkdir(dirname(file), { recursive: true });
239
+ const tmp = asidePath(file);
240
+ try {
241
+ const fh = await open(tmp, "w", o.mode ?? 438);
242
+ try {
243
+ await fh.writeFile(data);
244
+ await fh.sync();
245
+ } finally {
246
+ await fh.close();
247
+ }
248
+ await rename(tmp, file);
249
+ } catch (e) {
250
+ await rm(tmp, { force: true });
251
+ throw e;
252
+ }
253
+ }
254
+
255
+ //#endregion
256
+ //#region src/apicmd.ts
257
+ /** Where the key is kept: XOVRIS_CONFIG_DIR, else the platform's own per-user config folder. */
258
+ function configDir(env = process.env, platform = process.platform) {
259
+ if (env.XOVRIS_CONFIG_DIR) return env.XOVRIS_CONFIG_DIR;
260
+ if (platform === "win32") return join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "xovris");
261
+ if (platform === "darwin") return join(homedir(), "Library", "Application Support", "xovris");
262
+ return join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "xovris");
263
+ }
264
+ const credentialsFile = () => join(configDir(), "credentials.json");
265
+ async function readSaved() {
266
+ try {
267
+ const s = JSON.parse(await readFile(credentialsFile(), "utf8"));
268
+ return typeof s.token === "string" && s.token ? s : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ async function save(s) {
274
+ const f = credentialsFile();
275
+ await mkdir(dirname(f), { recursive: true });
276
+ await writeFileAtomic(f, `${JSON.stringify(s, null, 2)}\n`, { mode: 384 });
277
+ await chmod(f, 384).catch(() => void 0);
278
+ return f;
279
+ }
280
+ /** The key a command uses: XOVRIS_TOKEN first (CI), then the one `xovris login` saved for this API address. */
281
+ async function tokenFor(api, env = process.env) {
282
+ if (env.XOVRIS_TOKEN) return env.XOVRIS_TOKEN;
283
+ const s = await readSaved();
284
+ return s && s.api === api ? s.token : null;
285
+ }
286
+ async function login(o, ui, sys, secrets, io) {
287
+ const api = new Api(o.api, sys);
288
+ if (o.withToken) {
289
+ const token = (await io.stdin()).trim();
290
+ if (!token) {
291
+ io.err("No key on standard input. Pipe one in: xovris login --with-token < key.txt (make a key in Settings, Developers).\n");
292
+ return 2;
293
+ }
294
+ secrets.add(token);
295
+ api.token = token;
296
+ const me = await meOf(api);
297
+ if (!me.ok) {
298
+ io.err(`${me.message}\n${me.next}\n`);
299
+ return 1;
300
+ }
301
+ const f$1 = await save({
302
+ api: o.api,
303
+ token,
304
+ workspace: me.workspace,
305
+ savedAt: new Date(sys.now()).toISOString()
306
+ });
307
+ io.out(`Signed in to ${me.workspace?.slug ?? "your workspace"}. The key is kept in ${f$1}, readable by you alone.\n`);
308
+ return 0;
309
+ }
310
+ const { workspace } = await signIn(api, ui, sys);
311
+ secrets.add(api.token);
312
+ const f = await save({
313
+ api: o.api,
314
+ token: api.token,
315
+ workspace,
316
+ savedAt: new Date(sys.now()).toISOString()
317
+ });
318
+ io.out(`Signed in to ${workspace.slug}. The key is kept in ${f}, readable by you alone.\n`);
319
+ return 0;
320
+ }
321
+ async function logout(io) {
322
+ const had = await readSaved();
323
+ await rm(credentialsFile(), { force: true });
324
+ io.out(had ? "Signed out: the key on this computer is deleted. To stop it everywhere, revoke it in Settings, Developers.\n" : "Not signed in on this computer.\n");
325
+ return 0;
326
+ }
327
+ async function meOf(api) {
328
+ const r = await request(api, "GET", "/me", void 0, {});
329
+ if (r.status === 401) return {
330
+ ok: false,
331
+ message: "Xovris did not accept this key: it is unknown, revoked or expired.",
332
+ next: "Sign in again with xovris login, or make a new key in Settings, Developers."
333
+ };
334
+ if (r.status < 200 || r.status >= 300) return {
335
+ ok: false,
336
+ message: `Xovris answered ${r.status} to GET /v1/me.`,
337
+ next: problemNext(r.body)
338
+ };
339
+ const b = r.body ?? {};
340
+ return {
341
+ ok: true,
342
+ workspace: b.workspace?.id && b.workspace.slug ? {
343
+ id: b.workspace.id,
344
+ slug: b.workspace.slug
345
+ } : null,
346
+ who: b.email ?? b.user?.email ?? b.name ?? null
347
+ };
348
+ }
349
+ async function whoami(o, sys, secrets, io) {
350
+ const token = await tokenFor(o.api);
351
+ if (!token) {
352
+ io.err("Not signed in. Run xovris login, or set XOVRIS_TOKEN to a key from Settings, Developers.\n");
353
+ return 1;
354
+ }
355
+ secrets.add(token);
356
+ const api = new Api(o.api, sys);
357
+ api.token = token;
358
+ let me;
359
+ try {
360
+ me = await meOf(api);
361
+ } catch (e) {
362
+ io.err(`Xovris could not be reached at ${o.api} (${e instanceof Error ? e.message : String(e)}).\nCheck your connection or the --api address, then run xovris whoami again.\n`);
363
+ return 1;
364
+ }
365
+ if (!me.ok) {
366
+ io.err(`${me.message}\n${me.next}\n`);
367
+ return 1;
368
+ }
369
+ io.out(`${me.who ?? "Signed in"}${me.workspace ? ` - workspace ${me.workspace.slug}` : ""} at ${o.api}${process.env.XOVRIS_TOKEN ? " (key from XOVRIS_TOKEN)" : ""}\n`);
370
+ return 0;
371
+ }
372
+ /** -F key=value is typed like gh's: true, false, null and numbers become JSON values; @file reads a file; -f is always text. */
373
+ function fieldValue(v) {
374
+ if (v === "true") return true;
375
+ if (v === "false") return false;
376
+ if (v === "null") return null;
377
+ if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(v)) return Number(v);
378
+ return v;
379
+ }
380
+ /** The request body from -d (JSON, or @file) and the fields; a GET's fields become its query instead. */
381
+ async function bodyOf(a, method) {
382
+ const query = new URLSearchParams();
383
+ const obj = {};
384
+ for (const [list, typed] of [[a.fields, true], [a.rawFields, false]]) for (const f of list) {
385
+ const eq = f.indexOf("=");
386
+ if (eq < 1) return { problem: `"${f}" is not key=value.` };
387
+ const k = f.slice(0, eq);
388
+ let v = f.slice(eq + 1);
389
+ if (typed && typeof v === "string" && v.startsWith("@")) v = (await readFile(v.slice(1), "utf8")).replace(/\r?\n$/, "");
390
+ else if (typed) v = fieldValue(v);
391
+ if (method === "GET" || method === "HEAD") query.append(k, String(v));
392
+ else obj[k] = v;
393
+ }
394
+ if (a.data !== null) {
395
+ if (Object.keys(obj).length) return { problem: "Use -d or -F/-f fields, not both." };
396
+ const raw = a.data.startsWith("@") ? await readFile(a.data.slice(1), "utf8") : a.data;
397
+ try {
398
+ return {
399
+ body: JSON.parse(raw),
400
+ query
401
+ };
402
+ } catch {
403
+ return { problem: "-d is not valid JSON." };
404
+ }
405
+ }
406
+ return Object.keys(obj).length ? {
407
+ body: obj,
408
+ query
409
+ } : { query };
410
+ }
411
+ async function request(api, method, path, body, headers) {
412
+ const res = await api.raw(method, path, body, headers);
413
+ const text = await res.text();
414
+ let parsed = text;
415
+ try {
416
+ parsed = text ? JSON.parse(text) : null;
417
+ } catch {}
418
+ return {
419
+ status: res.status,
420
+ headers: res.headers,
421
+ body: parsed,
422
+ text
423
+ };
424
+ }
425
+ function problemNext(body) {
426
+ const b = body ?? {};
427
+ return b.next ?? b.nextStep ?? b.error?.nextStep ?? "Check the address and the request on https://xovris.ai/developers/.";
428
+ }
429
+ async function apiCommand(a, o, sys, secrets, io) {
430
+ if (!a.path) {
431
+ io.err("Name a path: xovris api /incidents, or xovris api -X POST /checks -F url=example.com\n");
432
+ return 2;
433
+ }
434
+ const token = await tokenFor(o.api);
435
+ if (!token) {
436
+ io.err("Not signed in. Run xovris login, or set XOVRIS_TOKEN to a key from Settings, Developers.\n");
437
+ return 1;
438
+ }
439
+ secrets.add(token);
440
+ const path = `/${a.path.replace(/^\/+/, "").replace(/^v1\//, "")}`;
441
+ const method = (a.method ?? (a.data !== null || a.fields.length || a.rawFields.length ? "POST" : "GET")).toUpperCase();
442
+ const built = await bodyOf(a, method);
443
+ if ("problem" in built) {
444
+ io.err(`${built.problem}\n`);
445
+ return 2;
446
+ }
447
+ const headers = {};
448
+ for (const h of a.headers) {
449
+ const i = h.indexOf(":");
450
+ if (i > 0) headers[h.slice(0, i).trim().toLowerCase()] = h.slice(i + 1).trim();
451
+ }
452
+ if (a.idempotency && ![
453
+ "GET",
454
+ "HEAD",
455
+ "OPTIONS"
456
+ ].includes(method) && !headers["idempotency-key"]) headers["idempotency-key"] = crypto.randomUUID();
457
+ const api = new Api(o.api, sys);
458
+ api.token = token;
459
+ const q = built.query.toString();
460
+ let r;
461
+ try {
462
+ r = await request(api, method, q ? `${path}${path.includes("?") ? "&" : "?"}${q}` : path, built.body, headers);
463
+ } catch {
464
+ io.err(`Could not reach ${o.api}. Check your connection, or --api for another address.\n`);
465
+ return 1;
466
+ }
467
+ if (a.include) io.out(secrets.redact(`HTTP ${r.status}\n${[...r.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n`));
468
+ const shown = typeof r.body === "string" ? r.body : JSON.stringify(r.body, null, 2);
469
+ if (r.status >= 200 && r.status < 300) {
470
+ if (shown) io.out(`${secrets.redact(shown)}\n`);
471
+ return 0;
472
+ }
473
+ io.err(`${secrets.redact(shown || `HTTP ${r.status}`)}\n`);
474
+ if (r.status === 401) io.err("Xovris did not accept this key: sign in again with xovris login, or set XOVRIS_TOKEN to a key from Settings, Developers.\n");
475
+ else if (r.status === 403) io.err("This key is not allowed to do that. Its permissions are on Settings, Developers; an owner or admin can change them.\n");
476
+ return 1;
477
+ }
478
+
479
+ //#endregion
480
+ export { whoami as a, Cancelled as c, ApiFailure as d, CLI_AGENT as f, tokenFor as i, clackUi as l, login as n, writeFileAtomic as o, CLI_VERSION as p, logout as r, signIn as s, apiCommand as t, Api as u };