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 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: --token flag → UNFAIRLY_TOKEN env → keychain/file → prompt.
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
- const rl = readline.createInterface({ input, output });
47
- console.log("Get a token at https://app.unfairly.ai/dashboard/setup (click 'Issue token').");
48
- token = (await rl.question("Paste mcp_* token: ")).trim();
49
- rl.close();
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.1.1",
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",