unfairly 0.1.1 → 0.2.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/dist/cmd/init.js +114 -8
- package/dist/cmd/login.js +95 -0
- package/dist/index.js +16 -1
- package/package.json +1 -1
package/dist/cmd/init.js
CHANGED
|
@@ -2,6 +2,9 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
5
8
|
import { SyncClient } from "../sync/api.js";
|
|
6
9
|
import { emptyManifest, writeManifest } from "../sync/manifest.js";
|
|
7
10
|
import { getToken, setToken } from "../sync/credentials.js";
|
|
@@ -10,6 +13,91 @@ const DEFAULT_API = "https://app.unfairly.ai";
|
|
|
10
13
|
function isTty() {
|
|
11
14
|
return Boolean(process.stdin.isTTY);
|
|
12
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Open a URL in the user's default browser. Best-effort, never throws.
|
|
18
|
+
* Uses spawn (not exec) — URL passed as an arg, never interpolated into
|
|
19
|
+
* a shell command, so no injection surface.
|
|
20
|
+
*/
|
|
21
|
+
function openBrowser(url) {
|
|
22
|
+
const opener = process.platform === "darwin"
|
|
23
|
+
? "open"
|
|
24
|
+
: process.platform === "win32"
|
|
25
|
+
? "cmd"
|
|
26
|
+
: "xdg-open";
|
|
27
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
28
|
+
try {
|
|
29
|
+
spawn(opener, args, { stdio: "ignore", detached: true }).unref();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// best-effort
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function successHtml() {
|
|
36
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI authorized</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:28px}p{margin:0;color:rgba(26,22,18,.7)}</style></head><body><div class="card"><h1>Authorized.</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
|
|
37
|
+
}
|
|
38
|
+
function failHtml(msg) {
|
|
39
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI failed</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:24px}p{margin:0;color:rgba(26,22,18,.7);font-size:14px}</style></head><body><div class="card"><h1>Authorization failed</h1><p>${msg}</p></div></body></html>`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Browser-based OAuth handoff. Spins up a loopback HTTP server, opens
|
|
43
|
+
* the browser to /cli-auth, waits for the redirect callback. Returns
|
|
44
|
+
* the mcp_* token, or null on timeout/cancel/error.
|
|
45
|
+
*/
|
|
46
|
+
async function browserAuthFlow(apiUrl) {
|
|
47
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let resolved = false;
|
|
50
|
+
const cleanup = (val) => {
|
|
51
|
+
if (resolved)
|
|
52
|
+
return;
|
|
53
|
+
resolved = true;
|
|
54
|
+
setTimeout(() => server.close(), 500);
|
|
55
|
+
resolve(val);
|
|
56
|
+
};
|
|
57
|
+
const server = http.createServer((req, res) => {
|
|
58
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
59
|
+
if (url.pathname !== "/done") {
|
|
60
|
+
res.writeHead(404).end("Not found");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const echoState = url.searchParams.get("state") ?? "";
|
|
64
|
+
const token = url.searchParams.get("token") ?? "";
|
|
65
|
+
if (echoState !== state) {
|
|
66
|
+
res
|
|
67
|
+
.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
|
68
|
+
.end(failHtml("State mismatch — possible CSRF. Try `unfairly init` again."));
|
|
69
|
+
cleanup(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!token.startsWith("mcp_")) {
|
|
73
|
+
res
|
|
74
|
+
.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
|
75
|
+
.end(failHtml("No token in callback."));
|
|
76
|
+
cleanup(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res
|
|
80
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
81
|
+
.end(successHtml());
|
|
82
|
+
cleanup(token);
|
|
83
|
+
});
|
|
84
|
+
const timeout = setTimeout(() => cleanup(null), 5 * 60_000);
|
|
85
|
+
server.on("close", () => clearTimeout(timeout));
|
|
86
|
+
server.listen(0, "127.0.0.1", () => {
|
|
87
|
+
const addr = server.address();
|
|
88
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
89
|
+
const cb = `http://localhost:${port}/done`;
|
|
90
|
+
const authUrl = new URL("/cli-auth", apiUrl);
|
|
91
|
+
authUrl.searchParams.set("cb", cb);
|
|
92
|
+
authUrl.searchParams.set("state", state);
|
|
93
|
+
authUrl.searchParams.set("name", "unfairly cli");
|
|
94
|
+
console.log("Opening browser to authorize...");
|
|
95
|
+
console.log(` ${authUrl.toString()}`);
|
|
96
|
+
console.log("(if it doesn't open, click the link above)");
|
|
97
|
+
openBrowser(authUrl.toString());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
13
101
|
export async function runInit(opts = {}) {
|
|
14
102
|
const cwd = process.cwd();
|
|
15
103
|
const target = path.join(cwd, ".unfairly");
|
|
@@ -36,25 +124,43 @@ export async function runInit(opts = {}) {
|
|
|
36
124
|
catch {
|
|
37
125
|
// didn't exist; fine
|
|
38
126
|
}
|
|
39
|
-
// Token resolution order:
|
|
127
|
+
// Token resolution order:
|
|
128
|
+
// 1. --token flag (agents, CI, scripts)
|
|
129
|
+
// 2. UNFAIRLY_TOKEN env (persisted shell config)
|
|
130
|
+
// 3. keychain / ~/.unfairly/credentials (last successful login)
|
|
131
|
+
// 4. browser-based OAuth (default in TTY mode — no copy/paste)
|
|
132
|
+
// 5. interactive prompt (fallback when --no-browser)
|
|
133
|
+
const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
|
|
40
134
|
let token = opts.token ?? process.env.UNFAIRLY_TOKEN ?? (await getToken());
|
|
41
135
|
if (!token) {
|
|
42
136
|
if (!isTty()) {
|
|
43
|
-
console.error("No token. Pass --token mcp_... or set UNFAIRLY_TOKEN env (non-interactive shell — can't prompt). Get one at https://app.unfairly.ai/dashboard/setup.");
|
|
137
|
+
console.error("No token. Pass --token mcp_... or set UNFAIRLY_TOKEN env (non-interactive shell — can't prompt or open a browser). Get one at https://app.unfairly.ai/dashboard/setup.");
|
|
44
138
|
process.exit(1);
|
|
45
139
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
140
|
+
if (!opts.noBrowser) {
|
|
141
|
+
console.log("Authorize this CLI in your browser (already signed in → one click).");
|
|
142
|
+
const fromBrowser = await browserAuthFlow(apiUrl);
|
|
143
|
+
if (fromBrowser) {
|
|
144
|
+
token = fromBrowser;
|
|
145
|
+
console.log("Authorized.");
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.log("Browser flow timed out or was cancelled. Falling back to manual paste.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!token) {
|
|
152
|
+
const rl = readline.createInterface({ input, output });
|
|
153
|
+
console.log("Get a token at https://app.unfairly.ai/dashboard/setup (click 'Issue token').");
|
|
154
|
+
token = (await rl.question("Paste mcp_* token: ")).trim();
|
|
155
|
+
rl.close();
|
|
156
|
+
}
|
|
50
157
|
}
|
|
51
|
-
if (!token.startsWith("mcp_")) {
|
|
158
|
+
if (!token || !token.startsWith("mcp_")) {
|
|
52
159
|
console.error("Token must start with mcp_");
|
|
53
160
|
process.exit(1);
|
|
54
161
|
}
|
|
55
162
|
await setToken(token);
|
|
56
163
|
// Verify token + list vaults
|
|
57
|
-
const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
|
|
58
164
|
const client = new SyncClient(apiUrl, token);
|
|
59
165
|
let status;
|
|
60
166
|
try {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { setToken } from "../sync/credentials.js";
|
|
5
|
+
const DEFAULT_API = "https://app.unfairly.ai";
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
const opener = process.platform === "darwin"
|
|
8
|
+
? "open"
|
|
9
|
+
: process.platform === "win32"
|
|
10
|
+
? "cmd"
|
|
11
|
+
: "xdg-open";
|
|
12
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
13
|
+
try {
|
|
14
|
+
spawn(opener, args, { stdio: "ignore", detached: true }).unref();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// best-effort
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI authorized</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:28px}p{margin:0;color:rgba(26,22,18,.7)}</style></head><body><div class="card"><h1>Authorized.</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
|
|
21
|
+
async function browserAuthFlow(apiUrl) {
|
|
22
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let resolved = false;
|
|
25
|
+
const cleanup = (val) => {
|
|
26
|
+
if (resolved)
|
|
27
|
+
return;
|
|
28
|
+
resolved = true;
|
|
29
|
+
setTimeout(() => server.close(), 500);
|
|
30
|
+
resolve(val);
|
|
31
|
+
};
|
|
32
|
+
const server = http.createServer((req, res) => {
|
|
33
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
34
|
+
if (url.pathname !== "/done") {
|
|
35
|
+
res.writeHead(404).end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const echoState = url.searchParams.get("state") ?? "";
|
|
39
|
+
const token = url.searchParams.get("token") ?? "";
|
|
40
|
+
if (echoState !== state || !token.startsWith("mcp_")) {
|
|
41
|
+
res.writeHead(400).end("Auth failed");
|
|
42
|
+
cleanup(null);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res
|
|
46
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
47
|
+
.end(SUCCESS_HTML);
|
|
48
|
+
cleanup(token);
|
|
49
|
+
});
|
|
50
|
+
const timeout = setTimeout(() => cleanup(null), 5 * 60_000);
|
|
51
|
+
server.on("close", () => clearTimeout(timeout));
|
|
52
|
+
server.listen(0, "127.0.0.1", () => {
|
|
53
|
+
const addr = server.address();
|
|
54
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
55
|
+
const cb = `http://localhost:${port}/done`;
|
|
56
|
+
const authUrl = new URL("/cli-auth", apiUrl);
|
|
57
|
+
authUrl.searchParams.set("cb", cb);
|
|
58
|
+
authUrl.searchParams.set("state", state);
|
|
59
|
+
authUrl.searchParams.set("name", "unfairly cli");
|
|
60
|
+
console.error(`\nOpening browser to authorize:\n ${authUrl.toString()}\n(if it doesn't open, click the link above)\n`);
|
|
61
|
+
openBrowser(authUrl.toString());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `unfairly login` — runs the OAuth handoff and prints a shell snippet
|
|
67
|
+
* the user can paste into their shell config. Useful for setting up
|
|
68
|
+
* Codex CLI's UNFAIRLY_MCP_TOKEN env var without copy-pasting tokens.
|
|
69
|
+
*
|
|
70
|
+
* Stdout = the export line (only). Stderr = friendly hints. So users can
|
|
71
|
+
* pipe: `unfairly login >> ~/.zshenv && source ~/.zshenv`.
|
|
72
|
+
*/
|
|
73
|
+
export async function runLogin(opts = {}) {
|
|
74
|
+
const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
|
|
75
|
+
const token = await browserAuthFlow(apiUrl);
|
|
76
|
+
if (!token) {
|
|
77
|
+
console.error("Login failed — browser flow timed out or was cancelled.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
await setToken(token);
|
|
81
|
+
if (!opts.quiet) {
|
|
82
|
+
console.error("Authorized.");
|
|
83
|
+
console.error("");
|
|
84
|
+
console.error("Add this line to your shell config (~/.zshenv on macOS / Linux):");
|
|
85
|
+
console.error("");
|
|
86
|
+
}
|
|
87
|
+
// The export line goes to STDOUT so it can be piped.
|
|
88
|
+
process.stdout.write(`export UNFAIRLY_MCP_TOKEN=${token}\n`);
|
|
89
|
+
process.stdout.write(`export UNFAIRLY_TOKEN=${token}\n`);
|
|
90
|
+
if (!opts.quiet) {
|
|
91
|
+
console.error("");
|
|
92
|
+
console.error("Or pipe directly: npx unfairly@latest login >> ~/.zshenv && source ~/.zshenv");
|
|
93
|
+
console.error("Then restart any agent host (Codex, Claude Code) that needs the env.");
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,21 +4,36 @@ import { hideBin } from "yargs/helpers";
|
|
|
4
4
|
await yargs(hideBin(process.argv))
|
|
5
5
|
.scriptName("unfairly")
|
|
6
6
|
.usage("$0 <cmd> [args]")
|
|
7
|
+
.command("login", "Authorize via browser; print export lines for ~/.zshenv (Codex / scripts)", (y) => y.option("quiet", {
|
|
8
|
+
type: "boolean",
|
|
9
|
+
alias: "q",
|
|
10
|
+
default: false,
|
|
11
|
+
describe: "Print only the export lines (for piping)",
|
|
12
|
+
}), async (argv) => {
|
|
13
|
+
const { runLogin } = await import("./cmd/login.js");
|
|
14
|
+
await runLogin({ quiet: argv.quiet });
|
|
15
|
+
})
|
|
7
16
|
.command("init", "Initialize .unfairly/ in cwd and pull all accessible vaults", (y) => y
|
|
8
17
|
.option("token", {
|
|
9
18
|
type: "string",
|
|
10
|
-
describe: "mcp_* token for non-interactive runs. Or set UNFAIRLY_TOKEN env.",
|
|
19
|
+
describe: "mcp_* token for non-interactive runs. Or set UNFAIRLY_TOKEN env. Default: browser OAuth.",
|
|
11
20
|
})
|
|
12
21
|
.option("yes", {
|
|
13
22
|
type: "boolean",
|
|
14
23
|
alias: "y",
|
|
15
24
|
default: false,
|
|
16
25
|
describe: "Skip the .unfairly/ overwrite confirmation",
|
|
26
|
+
})
|
|
27
|
+
.option("no-browser", {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
default: false,
|
|
30
|
+
describe: "Skip browser-based OAuth; prompt for a manual token paste instead.",
|
|
17
31
|
}), async (argv) => {
|
|
18
32
|
const { runInit } = await import("./cmd/init.js");
|
|
19
33
|
await runInit({
|
|
20
34
|
token: argv.token,
|
|
21
35
|
yes: argv.yes,
|
|
36
|
+
noBrowser: argv["no-browser"],
|
|
22
37
|
});
|
|
23
38
|
})
|
|
24
39
|
.command("pull [vault]", "Pull markdown from cloud", (y) => y.positional("vault", { type: "string" }), async (argv) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unfairly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI + stdio MCP for syncing Unfairly vaults to local markdown — the multiplayer context layer for AI agents",
|
|
5
5
|
"keywords": ["unfairly", "mcp", "ai", "context", "agents", "knowledge-base", "claude", "codex"],
|
|
6
6
|
"homepage": "https://unfairly.ai",
|