@pyxis-labs/cli 0.0.1 → 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 +46 -12
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +64 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +26 -0
- package/dist/commands/research.d.ts +1 -1
- package/dist/commands/research.js +61 -5
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +39 -0
- package/dist/index.js +11 -4
- package/dist/lib/__tests__/api.test.d.ts +1 -0
- package/dist/lib/__tests__/api.test.js +49 -0
- package/dist/lib/__tests__/callback.test.d.ts +1 -0
- package/dist/lib/__tests__/callback.test.js +27 -0
- package/dist/lib/__tests__/credentials.test.d.ts +1 -0
- package/dist/lib/__tests__/credentials.test.js +63 -0
- package/dist/lib/__tests__/ui.test.d.ts +1 -0
- package/dist/lib/__tests__/ui.test.js +40 -0
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +51 -0
- package/dist/lib/auth.d.ts +8 -0
- package/dist/lib/auth.js +42 -0
- package/dist/lib/browser.d.ts +3 -0
- package/dist/lib/browser.js +53 -0
- package/dist/lib/callback.d.ts +14 -0
- package/dist/lib/callback.js +110 -0
- package/dist/lib/credentials.d.ts +11 -0
- package/dist/lib/credentials.js +50 -0
- package/dist/lib/errors.d.ts +19 -0
- package/dist/lib/errors.js +25 -0
- package/dist/lib/ui.d.ts +16 -0
- package/dist/lib/ui.js +63 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# `@pyxis-labs/cli`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@pyxis-labs/cli)
|
|
4
|
+
[](https://github.com/pyxis-app/cli/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/pyxis-app/cli/actions/workflows/ci.yml)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Pyxis on the command line. Run Web3 research briefings from your terminal — same 5-agent pipeline as [usepyxis.com](https://www.usepyxis.com).
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
@@ -14,22 +16,43 @@ npx @pyxis-labs/cli health
|
|
|
14
16
|
|
|
15
17
|
Requires Node.js ≥ 18.17.
|
|
16
18
|
|
|
17
|
-
##
|
|
19
|
+
## Quickstart
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
pyxis
|
|
22
|
+
pyxis login # opens browser, sign in with wallet
|
|
23
|
+
pyxis research "ondo finance Q2" # 5-agent briefing in your terminal
|
|
24
|
+
pyxis status # check session
|
|
25
|
+
pyxis logout # clear local credentials
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
|
26
|
-
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
| Command | What it does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `pyxis login` | Browser-callback sign-in. Token saved to `~/.config/pyxis/credentials` (mode 0600). Valid 24h. |
|
|
33
|
+
| `pyxis logout` | Removes local credentials. (Token remains valid server-side until natural expiry.) |
|
|
34
|
+
| `pyxis status` | Shows current wallet + expiry. Exit 1 if not logged in. |
|
|
35
|
+
| `pyxis research <topic>` | Runs the 5-agent research pipeline against your topic. 30-90s typical. |
|
|
36
|
+
| `pyxis health` | Pings `/api/health` — confirms API + DB are up. |
|
|
37
|
+
| `pyxis help`, `--version` | Self-explanatory. |
|
|
27
38
|
|
|
28
39
|
## Configuration
|
|
29
40
|
|
|
30
41
|
| Env var | Default | Purpose |
|
|
31
42
|
|---|---|---|
|
|
32
43
|
| `PYXIS_API_BASE` | `https://usepyxis.com` | Override for self-hosted or staging |
|
|
44
|
+
| `NO_COLOR` | unset | When `1`, disable all ANSI colors and the banner |
|
|
45
|
+
| `PYXIS_NO_BANNER` | unset | When `1`, suppress only the login banner (keep colors) |
|
|
46
|
+
| `XDG_CONFIG_HOME` | `~/.config` | POSIX credentials directory base |
|
|
47
|
+
|
|
48
|
+
## Exit codes
|
|
49
|
+
|
|
50
|
+
| Code | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| 0 | Success |
|
|
53
|
+
| 1 | User error (not logged in, invalid topic, token expired) |
|
|
54
|
+
| 2 | Server / network error (5xx, timeout, rate-limited) |
|
|
55
|
+
| 130 | Interrupted (Ctrl+C) |
|
|
33
56
|
|
|
34
57
|
## Development
|
|
35
58
|
|
|
@@ -37,11 +60,22 @@ pyxis <command> [options]
|
|
|
37
60
|
git clone git@github.com:pyxis-app/cli.git
|
|
38
61
|
cd cli
|
|
39
62
|
npm install
|
|
40
|
-
npm
|
|
41
|
-
npm run
|
|
42
|
-
|
|
63
|
+
npm test # vitest
|
|
64
|
+
npm run dev -- health # run from source via tsx
|
|
65
|
+
npm run build # emit dist/
|
|
66
|
+
node dist/index.js login # run built artifact
|
|
43
67
|
```
|
|
44
68
|
|
|
69
|
+
## Related
|
|
70
|
+
|
|
71
|
+
| Project | What it is |
|
|
72
|
+
|---|---|
|
|
73
|
+
| [usepyxis.com](https://www.usepyxis.com) | Live web app — same 5-agent pipeline, rendered briefings |
|
|
74
|
+
| [docs.usepyxis.com](https://docs.usepyxis.com) | Docs site — methodology, sources, FAQ |
|
|
75
|
+
| [`pyxis-app/pyxis`](https://github.com/pyxis-app/pyxis) | Main app source (AGPL-3.0) |
|
|
76
|
+
| [`@pyxis-labs/web3-sources`](https://github.com/pyxis-app/web3-sources) | Sibling: typed TS clients for the 13 data sources Pyxis pulls from |
|
|
77
|
+
| [@pyxisbase](https://x.com/pyxisbase) on X | Project updates, build-in-public |
|
|
78
|
+
|
|
45
79
|
## License
|
|
46
80
|
|
|
47
|
-
MIT © Pyxis Authors. App itself (`pyxis-app/pyxis`) is AGPL-3.0 — this CLI is intentionally MIT so it can be embedded freely.
|
|
81
|
+
MIT © 2026 Pyxis Authors. App itself (`pyxis-app/pyxis`) is AGPL-3.0 — this CLI is intentionally MIT so it can be embedded freely.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function login(_args: string[]): Promise<number>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { performLogin } from "../lib/auth.js";
|
|
2
|
+
import { save } from "../lib/credentials.js";
|
|
3
|
+
import { printBanner, ok, step, meta, dim, bright, cyan, bold, spin } from "../lib/ui.js";
|
|
4
|
+
import { API_BASE } from "../config.js";
|
|
5
|
+
import { LoginCancelledError, AuthError } from "../lib/errors.js";
|
|
6
|
+
export async function login(_args) {
|
|
7
|
+
printBanner();
|
|
8
|
+
let spinner;
|
|
9
|
+
try {
|
|
10
|
+
const creds = await performLogin({
|
|
11
|
+
apiBase: API_BASE,
|
|
12
|
+
onUrl(url) {
|
|
13
|
+
process.stdout.write(step("Open this URL in your browser:") + "\n");
|
|
14
|
+
process.stdout.write(` ${bright(url)}\n`);
|
|
15
|
+
process.stdout.write(` ${dim("(attempting to launch your default browser…)")}` + "\n\n");
|
|
16
|
+
},
|
|
17
|
+
onWaiting() {
|
|
18
|
+
spinner = spin(`Waiting for sign-in... ${dim("(Ctrl+C to cancel)")}`);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
spinner?.stop();
|
|
22
|
+
await save(creds);
|
|
23
|
+
const expiresIn = formatRelative(creds.exp);
|
|
24
|
+
const expiresAt = new Date(creds.exp * 1000).toUTCString();
|
|
25
|
+
process.stdout.write("\n" + ok(bold("Logged in")) + "\n");
|
|
26
|
+
process.stdout.write(meta("wallet", cyan(shortWallet(creds.wallet))) + "\n");
|
|
27
|
+
process.stdout.write(meta("expires", `${expiresAt} ${dim(`(${expiresIn})`)}`) + "\n");
|
|
28
|
+
process.stdout.write(meta("stored at", credentialsPathStr()) + "\n");
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
spinner?.stop();
|
|
33
|
+
if (e instanceof LoginCancelledError) {
|
|
34
|
+
process.stderr.write("\n" + step(e.message) + "\n");
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (e instanceof AuthError) {
|
|
38
|
+
process.stderr.write("\n" + step(`Login failed: ${e.message}`) + "\n");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function shortWallet(w) {
|
|
45
|
+
return `${w.slice(0, 6)}…${w.slice(-4)}`;
|
|
46
|
+
}
|
|
47
|
+
function formatRelative(expUnixSec) {
|
|
48
|
+
const seconds = expUnixSec - Math.floor(Date.now() / 1000);
|
|
49
|
+
if (seconds <= 0)
|
|
50
|
+
return "expired";
|
|
51
|
+
const hours = Math.floor(seconds / 3600);
|
|
52
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
53
|
+
return `${hours}h${minutes.toString().padStart(2, "0")}m`;
|
|
54
|
+
}
|
|
55
|
+
function credentialsPathStr() {
|
|
56
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
return process.env.APPDATA
|
|
59
|
+
? `${process.env.APPDATA}\\pyxis\\credentials`
|
|
60
|
+
: "%APPDATA%\\pyxis\\credentials";
|
|
61
|
+
}
|
|
62
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
|
|
63
|
+
return xdg.replace(home, "~") + "/pyxis/credentials";
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logout(_args: string[]): Promise<number>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { clear, load } from "../lib/credentials.js";
|
|
2
|
+
import { ok, meta, dim, bold } from "../lib/ui.js";
|
|
3
|
+
export async function logout(_args) {
|
|
4
|
+
const existing = await load();
|
|
5
|
+
await clear();
|
|
6
|
+
process.stdout.write("\n" + ok(bold("Logged out")) + "\n");
|
|
7
|
+
if (existing) {
|
|
8
|
+
process.stdout.write(meta("removed", credentialsDisplay()) + "\n");
|
|
9
|
+
process.stdout.write(meta("note", dim("token remains valid server-side up to 24h until natural expiry")) +
|
|
10
|
+
"\n");
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
process.stdout.write(meta("info", dim("no credentials were present")) + "\n");
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
function credentialsDisplay() {
|
|
18
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
return process.env.APPDATA
|
|
21
|
+
? `${process.env.APPDATA}\\pyxis\\credentials`
|
|
22
|
+
: "%APPDATA%\\pyxis\\credentials";
|
|
23
|
+
}
|
|
24
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
|
|
25
|
+
return xdg.replace(home, "~") + "/pyxis/credentials";
|
|
26
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function research(
|
|
1
|
+
export declare function research(args: string[]): Promise<number>;
|
|
@@ -1,6 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { load } from "../lib/credentials.js";
|
|
2
|
+
import { callResearch } from "../lib/api.js";
|
|
3
|
+
import { ok, fail, step, meta, dim, cyan, bright, bold, spin } from "../lib/ui.js";
|
|
4
|
+
import { API_BASE } from "../config.js";
|
|
5
|
+
import { AuthError, RateLimitError, ServerError } from "../lib/errors.js";
|
|
6
|
+
export async function research(args) {
|
|
7
|
+
const topic = args.join(" ").trim();
|
|
8
|
+
if (!topic) {
|
|
9
|
+
process.stderr.write(fail("Usage: pyxis research <topic>") + "\n");
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
if (topic.length < 3 || topic.length > 200) {
|
|
13
|
+
process.stderr.write(fail("Topic must be 3-200 characters") + "\n");
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
const creds = await load();
|
|
17
|
+
if (!creds) {
|
|
18
|
+
process.stderr.write(fail("Not logged in") + "\n");
|
|
19
|
+
process.stderr.write(meta("hint", dim("run `pyxis login` first")) + "\n");
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write("\n");
|
|
23
|
+
process.stdout.write(step(`Running as ${cyan(shortWallet(creds.wallet))} on ${bright("usepyxis.com")}`) + "\n");
|
|
24
|
+
const spinner = spin("Researching…");
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await callResearch({ apiBase: API_BASE, credentials: creds, topic });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
spinner.stop();
|
|
32
|
+
if (e instanceof AuthError) {
|
|
33
|
+
process.stderr.write("\n" + fail(e.message) + "\n");
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
if (e instanceof RateLimitError) {
|
|
37
|
+
process.stderr.write("\n" + fail(e.message) + "\n");
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
if (e instanceof ServerError) {
|
|
41
|
+
process.stderr.write("\n" + fail(e.message) + "\n");
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
|
|
47
|
+
spinner.stop();
|
|
48
|
+
process.stdout.write("\n" + ok(bold(`Done in ${elapsedSec}s.`)) + "\n\n");
|
|
49
|
+
const markdown = result?.briefing?.markdown;
|
|
50
|
+
if (typeof markdown === "string" && markdown.length > 0) {
|
|
51
|
+
process.stdout.write(markdown);
|
|
52
|
+
if (!markdown.endsWith("\n"))
|
|
53
|
+
process.stdout.write("\n");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
function shortWallet(w) {
|
|
61
|
+
return `${w.slice(0, 6)}…${w.slice(-4)}`;
|
|
6
62
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function status(_args: string[]): Promise<number>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { load } from "../lib/credentials.js";
|
|
2
|
+
import { ok, fail, meta, dim, cyan, bold } from "../lib/ui.js";
|
|
3
|
+
export async function status(_args) {
|
|
4
|
+
const creds = await load();
|
|
5
|
+
if (!creds) {
|
|
6
|
+
process.stdout.write("\n" + fail(bold("Not logged in")) + "\n");
|
|
7
|
+
process.stdout.write(meta("hint", dim("run `pyxis login`")) + "\n");
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
11
|
+
const remainingSec = creds.exp - nowSec;
|
|
12
|
+
if (remainingSec <= 0) {
|
|
13
|
+
process.stdout.write("\n" + fail(bold("Session expired")) + "\n");
|
|
14
|
+
process.stdout.write(meta("wallet", cyan(shortWallet(creds.wallet))) + "\n");
|
|
15
|
+
process.stdout.write(meta("hint", dim("run `pyxis login` to refresh")) + "\n");
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const hours = Math.floor(remainingSec / 3600);
|
|
19
|
+
const minutes = Math.floor((remainingSec % 3600) / 60);
|
|
20
|
+
const expiresAt = new Date(creds.exp * 1000).toUTCString();
|
|
21
|
+
process.stdout.write("\n" + ok(bold("Logged in")) + "\n");
|
|
22
|
+
process.stdout.write(meta("wallet", cyan(shortWallet(creds.wallet))) + "\n");
|
|
23
|
+
process.stdout.write(meta("expires", `${expiresAt} ${dim(`(${hours}h${minutes.toString().padStart(2, "0")}m remaining)`)}`) + "\n");
|
|
24
|
+
process.stdout.write(meta("stored at", credentialsDisplay()) + "\n");
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
function shortWallet(w) {
|
|
28
|
+
return `${w.slice(0, 6)}…${w.slice(-4)}`;
|
|
29
|
+
}
|
|
30
|
+
function credentialsDisplay() {
|
|
31
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
32
|
+
if (process.platform === "win32") {
|
|
33
|
+
return process.env.APPDATA
|
|
34
|
+
? `${process.env.APPDATA}\\pyxis\\credentials`
|
|
35
|
+
: "%APPDATA%\\pyxis\\credentials";
|
|
36
|
+
}
|
|
37
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
|
|
38
|
+
return xdg.replace(home, "~") + "/pyxis/credentials";
|
|
39
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { health } from "./commands/health.js";
|
|
3
3
|
import { research } from "./commands/research.js";
|
|
4
|
+
import { login } from "./commands/login.js";
|
|
5
|
+
import { logout } from "./commands/logout.js";
|
|
6
|
+
import { status } from "./commands/status.js";
|
|
4
7
|
const USAGE = `pyxis — Web3 research from your terminal
|
|
5
8
|
|
|
6
9
|
Usage:
|
|
7
10
|
pyxis <command> [options]
|
|
8
11
|
|
|
9
12
|
Commands:
|
|
13
|
+
login Sign in with your wallet (opens browser)
|
|
14
|
+
logout Sign out and remove local credentials
|
|
15
|
+
status Show current sign-in state
|
|
10
16
|
research <topic> Run a research briefing (5-agent pipeline)
|
|
11
17
|
health Check usepyxis.com API status
|
|
12
18
|
help Show this message
|
|
@@ -29,10 +35,11 @@ async function main(argv) {
|
|
|
29
35
|
return 0;
|
|
30
36
|
}
|
|
31
37
|
switch (cmd) {
|
|
32
|
-
case "health":
|
|
33
|
-
|
|
34
|
-
case "
|
|
35
|
-
|
|
38
|
+
case "health": return health(rest);
|
|
39
|
+
case "login": return login(rest);
|
|
40
|
+
case "logout": return logout(rest);
|
|
41
|
+
case "status": return status(rest);
|
|
42
|
+
case "research": return research(rest);
|
|
36
43
|
default:
|
|
37
44
|
process.stderr.write(`Unknown command: ${cmd}\n\n${USAGE}`);
|
|
38
45
|
return 1;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { callResearch, isCredentialsFresh } from "../api.js";
|
|
3
|
+
import { AuthError, RateLimitError, ServerError } from "../errors.js";
|
|
4
|
+
const FRESH_CREDS = {
|
|
5
|
+
token: "jwt",
|
|
6
|
+
wallet: "0xabc",
|
|
7
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
8
|
+
savedAt: Date.now(),
|
|
9
|
+
};
|
|
10
|
+
const EXPIRED_CREDS = {
|
|
11
|
+
...FRESH_CREDS,
|
|
12
|
+
exp: Math.floor(Date.now() / 1000) - 60,
|
|
13
|
+
};
|
|
14
|
+
describe("isCredentialsFresh", () => {
|
|
15
|
+
it("true when exp is well in the future", () => {
|
|
16
|
+
expect(isCredentialsFresh(FRESH_CREDS)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it("false when exp is in the past", () => {
|
|
19
|
+
expect(isCredentialsFresh(EXPIRED_CREDS)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it("false when exp is within the 60s buffer", () => {
|
|
22
|
+
expect(isCredentialsFresh({ ...FRESH_CREDS, exp: Math.floor(Date.now() / 1000) + 30 })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("callResearch error mapping", () => {
|
|
26
|
+
const origFetch = global.fetch;
|
|
27
|
+
beforeEach(() => { global.fetch = vi.fn(); });
|
|
28
|
+
afterEach(() => { global.fetch = origFetch; });
|
|
29
|
+
it("throws AuthError when client-side credentials are stale", async () => {
|
|
30
|
+
await expect(callResearch({ apiBase: "http://x", credentials: EXPIRED_CREDS, topic: "btc" })).rejects.toBeInstanceOf(AuthError);
|
|
31
|
+
});
|
|
32
|
+
it("throws AuthError on 401 response", async () => {
|
|
33
|
+
global.fetch.mockResolvedValue(new Response(JSON.stringify({ error: "sign in required" }), { status: 401 }));
|
|
34
|
+
await expect(callResearch({ apiBase: "http://x", credentials: FRESH_CREDS, topic: "btc" })).rejects.toBeInstanceOf(AuthError);
|
|
35
|
+
});
|
|
36
|
+
it("throws RateLimitError on 429 with Retry-After", async () => {
|
|
37
|
+
global.fetch.mockResolvedValue(new Response("", { status: 429, headers: { "retry-after": "30" } }));
|
|
38
|
+
await expect(callResearch({ apiBase: "http://x", credentials: FRESH_CREDS, topic: "btc" })).rejects.toBeInstanceOf(RateLimitError);
|
|
39
|
+
});
|
|
40
|
+
it("throws ServerError on 500", async () => {
|
|
41
|
+
global.fetch.mockResolvedValue(new Response("oops", { status: 500 }));
|
|
42
|
+
await expect(callResearch({ apiBase: "http://x", credentials: FRESH_CREDS, topic: "btc" })).rejects.toBeInstanceOf(ServerError);
|
|
43
|
+
});
|
|
44
|
+
it("returns parsed result on 200", async () => {
|
|
45
|
+
global.fetch.mockResolvedValue(new Response(JSON.stringify({ briefing: { markdown: "# Hello" } }), { status: 200 }));
|
|
46
|
+
const result = await callResearch({ apiBase: "http://x", credentials: FRESH_CREDS, topic: "btc" });
|
|
47
|
+
expect(result.briefing?.markdown).toBe("# Hello");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { startCallbackServer } from "../callback.js";
|
|
3
|
+
describe("callback server", () => {
|
|
4
|
+
it("resolves on matching state + code", async () => {
|
|
5
|
+
const server = await startCallbackServer({ state: "ST", challenge: "CH" });
|
|
6
|
+
const fetched = fetch(`http://127.0.0.1:${server.port}/?state=ST&code=CH`);
|
|
7
|
+
const result = await server.awaitCallback({ timeoutMs: 5_000 });
|
|
8
|
+
expect(result.code).toBe("CH");
|
|
9
|
+
await fetched;
|
|
10
|
+
await server.close();
|
|
11
|
+
});
|
|
12
|
+
it("rejects mismatched state and keeps waiting", async () => {
|
|
13
|
+
const server = await startCallbackServer({ state: "ST", challenge: "CH" });
|
|
14
|
+
const badResp = await fetch(`http://127.0.0.1:${server.port}/?state=WRONG&code=CH`);
|
|
15
|
+
expect(badResp.status).toBe(400);
|
|
16
|
+
const fetched = fetch(`http://127.0.0.1:${server.port}/?state=ST&code=CH`);
|
|
17
|
+
const result = await server.awaitCallback({ timeoutMs: 5_000 });
|
|
18
|
+
expect(result.code).toBe("CH");
|
|
19
|
+
await fetched;
|
|
20
|
+
await server.close();
|
|
21
|
+
});
|
|
22
|
+
it("rejects after timeout when no valid callback arrives", async () => {
|
|
23
|
+
const server = await startCallbackServer({ state: "ST", challenge: "CH" });
|
|
24
|
+
await expect(server.awaitCallback({ timeoutMs: 100 })).rejects.toThrow(/timeout/i);
|
|
25
|
+
await server.close();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { save, load, clear, _setPathForTests } from "../credentials.js";
|
|
6
|
+
let dir;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
dir = await mkdtemp(join(tmpdir(), "pyxis-cred-"));
|
|
9
|
+
_setPathForTests(join(dir, "credentials"));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(dir, { recursive: true, force: true });
|
|
13
|
+
_setPathForTests(null);
|
|
14
|
+
});
|
|
15
|
+
const sample = {
|
|
16
|
+
token: "jwt-here",
|
|
17
|
+
wallet: "0xabc",
|
|
18
|
+
exp: 1234567890,
|
|
19
|
+
savedAt: Date.now(),
|
|
20
|
+
};
|
|
21
|
+
describe("credentials.save", () => {
|
|
22
|
+
it("writes JSON to the path", async () => {
|
|
23
|
+
await save(sample);
|
|
24
|
+
const raw = await readFile(join(dir, "credentials"), "utf8");
|
|
25
|
+
expect(JSON.parse(raw)).toEqual(sample);
|
|
26
|
+
});
|
|
27
|
+
it("sets mode 0600 on POSIX", async () => {
|
|
28
|
+
if (platform() === "win32")
|
|
29
|
+
return;
|
|
30
|
+
await save(sample);
|
|
31
|
+
const s = await stat(join(dir, "credentials"));
|
|
32
|
+
expect(s.mode & 0o777).toBe(0o600);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("credentials.load", () => {
|
|
36
|
+
it("returns null when file missing", async () => {
|
|
37
|
+
const result = await load();
|
|
38
|
+
expect(result).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it("returns parsed object when file exists", async () => {
|
|
41
|
+
await save(sample);
|
|
42
|
+
const result = await load();
|
|
43
|
+
expect(result).toEqual(sample);
|
|
44
|
+
});
|
|
45
|
+
it("returns null on malformed JSON", async () => {
|
|
46
|
+
await writeFile(join(dir, "credentials"), "not json", "utf8");
|
|
47
|
+
const result = await load();
|
|
48
|
+
expect(result).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
it("returns null when JSON shape does not match Credentials", async () => {
|
|
51
|
+
await writeFile(join(dir, "credentials"), JSON.stringify({ token: "only" }), "utf8");
|
|
52
|
+
const result = await load();
|
|
53
|
+
expect(result).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("credentials.clear", () => {
|
|
57
|
+
it("removes the file (idempotent)", async () => {
|
|
58
|
+
await save(sample);
|
|
59
|
+
await clear();
|
|
60
|
+
expect(await load()).toBeNull();
|
|
61
|
+
await expect(clear()).resolves.toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ok, fail, warn, step, meta, isColorEnabled } from "../ui.js";
|
|
3
|
+
// Strip ANSI escapes so assertions don't depend on TTY/FORCE_COLOR detection
|
|
4
|
+
// (picocolors emits codes when CI sets FORCE_COLOR even though our isColorEnabled
|
|
5
|
+
// returns false — the helpers always run picocolors regardless).
|
|
6
|
+
// eslint-disable-next-line no-control-regex
|
|
7
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
8
|
+
describe("ui status helpers", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
delete process.env.NO_COLOR;
|
|
11
|
+
delete process.env.PYXIS_NO_BANNER;
|
|
12
|
+
});
|
|
13
|
+
it("ok wraps with green check", () => {
|
|
14
|
+
const out = stripAnsi(ok("Logged in"));
|
|
15
|
+
expect(out).toContain("✓");
|
|
16
|
+
expect(out).toContain("Logged in");
|
|
17
|
+
});
|
|
18
|
+
it("fail wraps with red x", () => {
|
|
19
|
+
const out = stripAnsi(fail("oops"));
|
|
20
|
+
expect(out).toContain("✗");
|
|
21
|
+
expect(out).toContain("oops");
|
|
22
|
+
});
|
|
23
|
+
it("warn uses yellow tilde", () => {
|
|
24
|
+
const out = stripAnsi(warn("careful"));
|
|
25
|
+
expect(out).toContain("~");
|
|
26
|
+
expect(out).toContain("careful");
|
|
27
|
+
});
|
|
28
|
+
it("step uses cyan arrow", () => {
|
|
29
|
+
const out = stripAnsi(step("Opening browser"));
|
|
30
|
+
expect(out).toContain("→");
|
|
31
|
+
});
|
|
32
|
+
it("meta aligns label width 11", () => {
|
|
33
|
+
const out = stripAnsi(meta("wallet", "0xabc"));
|
|
34
|
+
expect(out).toMatch(/wallet\s+0xabc/);
|
|
35
|
+
});
|
|
36
|
+
it("isColorEnabled returns false when NO_COLOR=1", () => {
|
|
37
|
+
process.env.NO_COLOR = "1";
|
|
38
|
+
expect(isColorEnabled()).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Credentials } from "./credentials.js";
|
|
2
|
+
export declare function isCredentialsFresh(c: Credentials, now?: number): boolean;
|
|
3
|
+
export interface ResearchResult {
|
|
4
|
+
briefing?: {
|
|
5
|
+
markdown?: string;
|
|
6
|
+
};
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare function callResearch(opts: {
|
|
10
|
+
apiBase: string;
|
|
11
|
+
credentials: Credentials;
|
|
12
|
+
topic: string;
|
|
13
|
+
}): Promise<ResearchResult>;
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AuthError, RateLimitError, ServerError } from "./errors.js";
|
|
2
|
+
const CLOCK_SKEW_BUFFER_SEC = 60;
|
|
3
|
+
const RESEARCH_TIMEOUT_MS = 120_000;
|
|
4
|
+
export function isCredentialsFresh(c, now = Date.now()) {
|
|
5
|
+
const nowSec = Math.floor(now / 1000);
|
|
6
|
+
return c.exp - nowSec > CLOCK_SKEW_BUFFER_SEC;
|
|
7
|
+
}
|
|
8
|
+
export async function callResearch(opts) {
|
|
9
|
+
if (!isCredentialsFresh(opts.credentials)) {
|
|
10
|
+
throw new AuthError("Token expired or about to expire — run `pyxis login`");
|
|
11
|
+
}
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await fetch(`${opts.apiBase}/api/research`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
Cookie: `pyxis_session=${opts.credentials.token}`,
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({ topic: opts.topic }),
|
|
21
|
+
signal: AbortSignal.timeout(RESEARCH_TIMEOUT_MS),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e.name === "TimeoutError") {
|
|
26
|
+
throw new ServerError(`Request timed out after ${RESEARCH_TIMEOUT_MS / 1000}s`, 0);
|
|
27
|
+
}
|
|
28
|
+
throw new ServerError(`Could not reach ${opts.apiBase}: ${e.message}`, 0);
|
|
29
|
+
}
|
|
30
|
+
if (res.status === 401) {
|
|
31
|
+
throw new AuthError("Authentication failed — run `pyxis login`");
|
|
32
|
+
}
|
|
33
|
+
if (res.status === 429) {
|
|
34
|
+
const ra = res.headers.get("retry-after");
|
|
35
|
+
const seconds = ra ? Number(ra) : null;
|
|
36
|
+
throw new RateLimitError(seconds != null
|
|
37
|
+
? `Rate limited — retry after ${seconds}s`
|
|
38
|
+
: "Rate limited — try again later", seconds);
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 400) {
|
|
41
|
+
const body = (await res.json().catch(() => ({ error: "bad request" })));
|
|
42
|
+
throw new AuthError(body.error ?? "bad request");
|
|
43
|
+
}
|
|
44
|
+
if (res.status >= 500) {
|
|
45
|
+
throw new ServerError(`Server returned ${res.status}`, res.status);
|
|
46
|
+
}
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new ServerError(`Unexpected status ${res.status}`, res.status);
|
|
49
|
+
}
|
|
50
|
+
return (await res.json());
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Credentials } from "./credentials.js";
|
|
2
|
+
export interface LoginOptions {
|
|
3
|
+
apiBase: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
onUrl?: (url: string) => void;
|
|
6
|
+
onWaiting?: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function performLogin(opts: LoginOptions): Promise<Credentials>;
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { startCallbackServer } from "./callback.js";
|
|
3
|
+
import { openBrowser } from "./browser.js";
|
|
4
|
+
import { AuthError, LoginCancelledError, TimeoutError } from "./errors.js";
|
|
5
|
+
export async function performLogin(opts) {
|
|
6
|
+
const state = randomBytes(16).toString("hex");
|
|
7
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
8
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
9
|
+
const server = await startCallbackServer({ state, challenge });
|
|
10
|
+
const url = `${opts.apiBase}/cli-auth?state=${state}&port=${server.port}&challenge=${encodeURIComponent(challenge)}`;
|
|
11
|
+
opts.onUrl?.(url);
|
|
12
|
+
await openBrowser(url);
|
|
13
|
+
opts.onWaiting?.();
|
|
14
|
+
let callback;
|
|
15
|
+
try {
|
|
16
|
+
callback = await server.awaitCallback({ timeoutMs: opts.timeoutMs ?? 5 * 60_000 });
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
await server.close();
|
|
20
|
+
if (e instanceof TimeoutError) {
|
|
21
|
+
throw new LoginCancelledError("Login cancelled or timed out — run `pyxis login` again");
|
|
22
|
+
}
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
await server.close();
|
|
26
|
+
const res = await fetch(`${opts.apiBase}/api/cli/token`, {
|
|
27
|
+
method: "PUT",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ code: callback.code, verifier }),
|
|
30
|
+
signal: AbortSignal.timeout(15_000),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new AuthError(`Token exchange failed (HTTP ${res.status})`);
|
|
34
|
+
}
|
|
35
|
+
const payload = (await res.json());
|
|
36
|
+
return {
|
|
37
|
+
token: payload.token,
|
|
38
|
+
wallet: payload.wallet,
|
|
39
|
+
exp: payload.exp,
|
|
40
|
+
savedAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
function isWsl() {
|
|
6
|
+
if (process.platform !== "linux")
|
|
7
|
+
return false;
|
|
8
|
+
if (process.env.WSL_DISTRO_NAME)
|
|
9
|
+
return true;
|
|
10
|
+
try {
|
|
11
|
+
const proc = readFileSync("/proc/version", "utf8");
|
|
12
|
+
return /microsoft|wsl/i.test(proc);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function openBrowser(url) {
|
|
19
|
+
// WSL2 → reach into Windows to launch the actual user-facing browser.
|
|
20
|
+
// We deliberately use PowerShell Start-Process instead of `cmd.exe /c start`
|
|
21
|
+
// or `wslview` because cmd.exe re-parses the command line and treats `&` in
|
|
22
|
+
// a URL as a command separator, which fragments URLs like
|
|
23
|
+
// /cli-auth?state=X&port=Y&challenge=Z into multiple browser-launch attempts
|
|
24
|
+
// (one with the full URL, one with just ?state=X). Start-Process keeps the
|
|
25
|
+
// URL atomic.
|
|
26
|
+
if (isWsl()) {
|
|
27
|
+
const safeUrl = url.replace(/"/g, '\\"');
|
|
28
|
+
try {
|
|
29
|
+
await execFileAsync("powershell.exe", [
|
|
30
|
+
"-NoProfile",
|
|
31
|
+
"-Command",
|
|
32
|
+
`Start-Process "${safeUrl}"`,
|
|
33
|
+
]);
|
|
34
|
+
return { opened: true };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { opened: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const cmd = process.platform === "darwin"
|
|
41
|
+
? "open"
|
|
42
|
+
: process.platform === "win32"
|
|
43
|
+
? "cmd"
|
|
44
|
+
: "xdg-open";
|
|
45
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
46
|
+
try {
|
|
47
|
+
await execFileAsync(cmd, args);
|
|
48
|
+
return { opened: true };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { opened: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CallbackResult {
|
|
2
|
+
code: string;
|
|
3
|
+
}
|
|
4
|
+
export interface CallbackServer {
|
|
5
|
+
port: number;
|
|
6
|
+
awaitCallback(opts: {
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
}): Promise<CallbackResult>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function startCallbackServer(expected: {
|
|
12
|
+
state: string;
|
|
13
|
+
challenge: string;
|
|
14
|
+
}): Promise<CallbackServer>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { TimeoutError } from "./errors.js";
|
|
3
|
+
const SUCCESS_PAGE = `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<title>pyxis · signed in</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg:#0a0c10; --panel:#11141a; --border:#1f2630;
|
|
11
|
+
--fg:#d6deeb; --dim:#7a8aa3; --cyan:#4fc3f7;
|
|
12
|
+
--green:#4ade80; --accent:#67e8f9;
|
|
13
|
+
}
|
|
14
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
15
|
+
html,body{
|
|
16
|
+
background:var(--bg);color:var(--fg);min-height:100vh;
|
|
17
|
+
font-family:'JetBrains Mono','SF Mono',Menlo,Consolas,monospace;
|
|
18
|
+
display:flex;align-items:center;justify-content:center;padding:24px;
|
|
19
|
+
}
|
|
20
|
+
.card{
|
|
21
|
+
background:var(--panel);border:1px solid var(--border);border-radius:14px;
|
|
22
|
+
padding:48px 40px;max-width:540px;width:100%;text-align:center;
|
|
23
|
+
box-shadow:0 24px 60px -24px rgba(0,0,0,0.6),
|
|
24
|
+
0 0 0 1px rgba(79,195,247,0.04);
|
|
25
|
+
}
|
|
26
|
+
.logo{color:var(--cyan);font-size:11px;line-height:1.05;white-space:pre;margin-bottom:10px}
|
|
27
|
+
.tagline{color:var(--dim);font-size:11px;letter-spacing:0.25em;text-transform:uppercase;margin-bottom:36px}
|
|
28
|
+
.check{
|
|
29
|
+
width:64px;height:64px;border-radius:50%;
|
|
30
|
+
background:rgba(74,222,128,0.10);
|
|
31
|
+
color:var(--green);font-size:32px;font-weight:700;
|
|
32
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
33
|
+
margin-bottom:18px;
|
|
34
|
+
box-shadow:0 0 0 1px rgba(74,222,128,0.18),
|
|
35
|
+
0 0 24px -4px rgba(74,222,128,0.25);
|
|
36
|
+
}
|
|
37
|
+
h1{font-size:22px;color:#fff;margin-bottom:10px;font-weight:600;letter-spacing:-0.01em}
|
|
38
|
+
p{color:var(--dim);font-size:13px;line-height:1.65;max-width:380px;margin:0 auto}
|
|
39
|
+
.row{
|
|
40
|
+
margin-top:28px;padding-top:24px;border-top:1px solid var(--border);
|
|
41
|
+
color:var(--accent);font-size:10px;letter-spacing:0.3em;text-transform:uppercase;
|
|
42
|
+
}
|
|
43
|
+
.row .key{color:var(--dim);margin-right:8px}
|
|
44
|
+
@media (prefers-reduced-motion:no-preference){
|
|
45
|
+
.check{animation:pulse 2.4s ease-in-out infinite}
|
|
46
|
+
@keyframes pulse{
|
|
47
|
+
0%,100%{box-shadow:0 0 0 1px rgba(74,222,128,0.18),0 0 24px -4px rgba(74,222,128,0.25)}
|
|
48
|
+
50%{box-shadow:0 0 0 1px rgba(74,222,128,0.35),0 0 32px -2px rgba(74,222,128,0.4)}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div class="card" role="status" aria-live="polite">
|
|
55
|
+
<div class="logo">██████╗ ██╗ ██╗██╗ ██╗██╗███████╗
|
|
56
|
+
██╔══██╗╚██╗ ██╔╝╚██╗██╔╝██║██╔════╝
|
|
57
|
+
██████╔╝ ╚████╔╝ ╚███╔╝ ██║███████╗
|
|
58
|
+
██╔═══╝ ╚██╔╝ ██╔██╗ ██║╚════██║
|
|
59
|
+
██║ ██║ ██╔╝ ██╗██║███████║
|
|
60
|
+
╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝</div>
|
|
61
|
+
<div class="tagline">the research swarm</div>
|
|
62
|
+
<div class="check" aria-hidden="true">✓</div>
|
|
63
|
+
<h1>Signed in</h1>
|
|
64
|
+
<p>The CLI received your token. You can close this tab and return to your terminal to start researching.</p>
|
|
65
|
+
<div class="row"><span class="key">next</span><span id="hint">type pyxis research <topic></span></div>
|
|
66
|
+
</div>
|
|
67
|
+
<script>
|
|
68
|
+
// Try to auto-close (only works if the tab was opened by JS, which it
|
|
69
|
+
// wasn't — but harmless to attempt). Otherwise the user closes manually.
|
|
70
|
+
setTimeout(() => { try { window.close(); } catch {} }, 4000);
|
|
71
|
+
</script>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`;
|
|
74
|
+
export async function startCallbackServer(expected) {
|
|
75
|
+
let resolveFn = null;
|
|
76
|
+
let rejectFn = null;
|
|
77
|
+
const server = createServer((req, res) => {
|
|
78
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
79
|
+
const state = url.searchParams.get("state");
|
|
80
|
+
const code = url.searchParams.get("code");
|
|
81
|
+
if (state !== expected.state || code !== expected.challenge) {
|
|
82
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
83
|
+
res.end("<h1>Invalid callback</h1><p>State or code mismatch.</p>");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
87
|
+
res.end(SUCCESS_PAGE);
|
|
88
|
+
if (resolveFn)
|
|
89
|
+
resolveFn({ code });
|
|
90
|
+
});
|
|
91
|
+
await new Promise((resolve) => {
|
|
92
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
93
|
+
});
|
|
94
|
+
const port = server.address().port;
|
|
95
|
+
return {
|
|
96
|
+
port,
|
|
97
|
+
awaitCallback({ timeoutMs }) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
resolveFn = resolve;
|
|
100
|
+
rejectFn = reject;
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
reject(new TimeoutError(`Callback timeout after ${timeoutMs}ms`));
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
async close() {
|
|
107
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Credentials {
|
|
2
|
+
token: string;
|
|
3
|
+
wallet: string;
|
|
4
|
+
exp: number;
|
|
5
|
+
savedAt: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function _setPathForTests(p: string | null): void;
|
|
8
|
+
export declare function credentialsPath(): string;
|
|
9
|
+
export declare function save(c: Credentials): Promise<void>;
|
|
10
|
+
export declare function load(): Promise<Credentials | null>;
|
|
11
|
+
export declare function clear(): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
4
|
+
let pathOverride = null;
|
|
5
|
+
export function _setPathForTests(p) {
|
|
6
|
+
pathOverride = p;
|
|
7
|
+
}
|
|
8
|
+
export function credentialsPath() {
|
|
9
|
+
if (pathOverride)
|
|
10
|
+
return pathOverride;
|
|
11
|
+
if (platform() === "win32") {
|
|
12
|
+
const appdata = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
|
|
13
|
+
return join(appdata, "pyxis", "credentials");
|
|
14
|
+
}
|
|
15
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
16
|
+
return join(xdg, "pyxis", "credentials");
|
|
17
|
+
}
|
|
18
|
+
export async function save(c) {
|
|
19
|
+
const p = credentialsPath();
|
|
20
|
+
await mkdir(dirname(p), { recursive: true });
|
|
21
|
+
await writeFile(p, JSON.stringify(c, null, 2) + "\n", "utf8");
|
|
22
|
+
if (platform() !== "win32") {
|
|
23
|
+
await chmod(p, 0o600);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function load() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
if (typeof parsed?.token === "string" &&
|
|
31
|
+
typeof parsed?.wallet === "string" &&
|
|
32
|
+
typeof parsed?.exp === "number" &&
|
|
33
|
+
typeof parsed?.savedAt === "number") {
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function clear() {
|
|
43
|
+
try {
|
|
44
|
+
await unlink(credentialsPath());
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e.code !== "ENOENT")
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class AuthError extends Error {
|
|
2
|
+
readonly code: "AUTH_ERROR";
|
|
3
|
+
}
|
|
4
|
+
export declare class RateLimitError extends Error {
|
|
5
|
+
retryAfterSeconds: number | null;
|
|
6
|
+
readonly code: "RATE_LIMIT";
|
|
7
|
+
constructor(message: string, retryAfterSeconds?: number | null);
|
|
8
|
+
}
|
|
9
|
+
export declare class ServerError extends Error {
|
|
10
|
+
status: number;
|
|
11
|
+
readonly code: "SERVER_ERROR";
|
|
12
|
+
constructor(message: string, status: number);
|
|
13
|
+
}
|
|
14
|
+
export declare class LoginCancelledError extends Error {
|
|
15
|
+
readonly code: "LOGIN_CANCELLED";
|
|
16
|
+
}
|
|
17
|
+
export declare class TimeoutError extends Error {
|
|
18
|
+
readonly code: "TIMEOUT";
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class AuthError extends Error {
|
|
2
|
+
code = "AUTH_ERROR";
|
|
3
|
+
}
|
|
4
|
+
export class RateLimitError extends Error {
|
|
5
|
+
retryAfterSeconds;
|
|
6
|
+
code = "RATE_LIMIT";
|
|
7
|
+
constructor(message, retryAfterSeconds = null) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ServerError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
code = "SERVER_ERROR";
|
|
15
|
+
constructor(message, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class LoginCancelledError extends Error {
|
|
21
|
+
code = "LOGIN_CANCELLED";
|
|
22
|
+
}
|
|
23
|
+
export class TimeoutError extends Error {
|
|
24
|
+
code = "TIMEOUT";
|
|
25
|
+
}
|
package/dist/lib/ui.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function isColorEnabled(): boolean;
|
|
2
|
+
export declare function isBannerEnabled(): boolean;
|
|
3
|
+
export declare const ok: (s: string) => string;
|
|
4
|
+
export declare const fail: (s: string) => string;
|
|
5
|
+
export declare const warn: (s: string) => string;
|
|
6
|
+
export declare const step: (s: string) => string;
|
|
7
|
+
export declare const meta: (label: string, value: string) => string;
|
|
8
|
+
export declare const dim: (s: string) => string;
|
|
9
|
+
export declare const bold: (s: string) => string;
|
|
10
|
+
export declare const cyan: (s: string) => string;
|
|
11
|
+
export declare const bright: (s: string) => string;
|
|
12
|
+
export declare function printBanner(): void;
|
|
13
|
+
export interface Spinner {
|
|
14
|
+
stop(finalLine?: string): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function spin(label: string): Spinner;
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import c from "picocolors";
|
|
2
|
+
import cliSpinners from "cli-spinners";
|
|
3
|
+
export function isColorEnabled() {
|
|
4
|
+
if (process.env.NO_COLOR === "1")
|
|
5
|
+
return false;
|
|
6
|
+
return process.stdout.isTTY === true;
|
|
7
|
+
}
|
|
8
|
+
export function isBannerEnabled() {
|
|
9
|
+
if (process.env.PYXIS_NO_BANNER === "1")
|
|
10
|
+
return false;
|
|
11
|
+
return isColorEnabled();
|
|
12
|
+
}
|
|
13
|
+
export const ok = (s) => ` ${c.green("✓")} ${s}`;
|
|
14
|
+
export const fail = (s) => ` ${c.red("✗")} ${s}`;
|
|
15
|
+
export const warn = (s) => ` ${c.yellow("~")} ${s}`;
|
|
16
|
+
export const step = (s) => ` ${c.cyan("→")} ${s}`;
|
|
17
|
+
export const meta = (label, value) => ` ${c.dim(label.padEnd(11))}${value}`;
|
|
18
|
+
export const dim = (s) => c.dim(s);
|
|
19
|
+
export const bold = (s) => c.bold(s);
|
|
20
|
+
export const cyan = (s) => c.cyan(s);
|
|
21
|
+
export const bright = (s) => c.cyanBright(s);
|
|
22
|
+
const BANNER = [
|
|
23
|
+
"██████╗ ██╗ ██╗██╗ ██╗██╗███████╗",
|
|
24
|
+
"██╔══██╗╚██╗ ██╔╝╚██╗██╔╝██║██╔════╝",
|
|
25
|
+
"██████╔╝ ╚████╔╝ ╚███╔╝ ██║███████╗",
|
|
26
|
+
"██╔═══╝ ╚██╔╝ ██╔██╗ ██║╚════██║",
|
|
27
|
+
"██║ ██║ ██╔╝ ██╗██║███████║",
|
|
28
|
+
"╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝",
|
|
29
|
+
];
|
|
30
|
+
export function printBanner() {
|
|
31
|
+
if (!isBannerEnabled())
|
|
32
|
+
return;
|
|
33
|
+
for (const line of BANNER) {
|
|
34
|
+
process.stdout.write(` ${c.cyan(line)}\n`);
|
|
35
|
+
}
|
|
36
|
+
process.stdout.write(` ${c.dim("the research swarm")}\n\n`);
|
|
37
|
+
}
|
|
38
|
+
export function spin(label) {
|
|
39
|
+
if (!isColorEnabled()) {
|
|
40
|
+
process.stdout.write(` ${label}\n`);
|
|
41
|
+
return {
|
|
42
|
+
stop(finalLine) {
|
|
43
|
+
if (finalLine)
|
|
44
|
+
process.stdout.write(`${finalLine}\n`);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const frames = cliSpinners.dots.frames;
|
|
49
|
+
const intervalMs = cliSpinners.dots.interval;
|
|
50
|
+
let i = 0;
|
|
51
|
+
const timer = setInterval(() => {
|
|
52
|
+
process.stdout.write(`\r ${c.cyan(frames[i % frames.length] ?? "")} ${label}`);
|
|
53
|
+
i++;
|
|
54
|
+
}, intervalMs);
|
|
55
|
+
return {
|
|
56
|
+
stop(finalLine) {
|
|
57
|
+
clearInterval(timer);
|
|
58
|
+
process.stdout.write(`\r\x1b[K`);
|
|
59
|
+
if (finalLine)
|
|
60
|
+
process.stdout.write(`${finalLine}\n`);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyxis-labs/cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Pyxis on the command line — run Web3 research from your terminal. Hits usepyxis.com.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"pyxis": "
|
|
8
|
+
"pyxis": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"build": "tsc",
|
|
20
20
|
"typecheck": "tsc --noEmit",
|
|
21
21
|
"dev": "node --import=tsx/esm src/index.ts",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
22
24
|
"prepublishOnly": "npm run build"
|
|
23
25
|
},
|
|
24
26
|
"keywords": [
|
|
@@ -40,6 +42,11 @@
|
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"@types/node": "^22.10.0",
|
|
42
44
|
"tsx": "^4.19.0",
|
|
43
|
-
"typescript": "^5.6.0"
|
|
45
|
+
"typescript": "^5.6.0",
|
|
46
|
+
"vitest": "~2.1.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"cli-spinners": "~3.0.0",
|
|
50
|
+
"picocolors": "~1.1.0"
|
|
44
51
|
}
|
|
45
52
|
}
|