88eggs-cli 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ptk-studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # 88eggs-cli
2
+
3
+ CLI for [88eggs](https://github.com/thomaskwan/88eggs-frontend). Signs
4
+ in with your Google account (the same one you'd use on the 88eggs
5
+ frontend) and talks to
6
+ [`88eggs-backend`](https://github.com/thomaskwan/88eggs-backend) on
7
+ your behalf.
8
+
9
+ This exists so [`88eggs-skills`](https://github.com/ptk-studio/88eggs-skills)
10
+ has something to drive, mirroring how `vercel-labs/agent-skills`' deploy
11
+ skill drives the `vercel` CLI and `supabase/agent-skills` drives the
12
+ `supabase` CLI — a skill shells out to `88eggs`, `88eggs` owns its own
13
+ login, and no skill ever handles a raw token itself.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm install
19
+ pnpm build
20
+ npm link # makes the `88eggs` command available globally
21
+ ```
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ 88eggs login # sign in with Google via your browser
27
+ 88eggs logout # sign out, remove stored credentials
28
+ 88eggs whoami # show the currently signed-in account
29
+ 88eggs projects list # list your projects (--scope mine|shared|all)
30
+ ```
31
+
32
+ ## Using this with an agent
33
+
34
+ Install [`88eggs-skills`](https://github.com/ptk-studio/88eggs-skills)
35
+ to let an agent (Claude Code, etc.) drive this CLI on your behalf:
36
+
37
+ ```bash
38
+ npx skills add ptk-studio/88eggs-skills --skill list-projects
39
+ ```
40
+
41
+ The skill checks `88eggs whoami` first and only runs `88eggs login`
42
+ after asking you — since that opens your browser for a real Google
43
+ sign-in — so `88eggs-cli` still needs to be installed and on your
44
+ `PATH` (see **Install** above) before the skill can do anything.
45
+
46
+ ## How auth works
47
+
48
+ `88eggs login` runs a real OAuth flow against the same Supabase project
49
+ `88eggs-backend`/`88eggs-frontend` already use — there's no separate
50
+ 88eggs-specific auth server, and no new backend feature was needed for
51
+ this to work:
52
+
53
+ 1. Opens your browser to Supabase's `/auth/v1/authorize?provider=google`
54
+ (the same endpoint the frontend's "Continue with Google" button
55
+ hits), with `redirect_to` pointed at a local callback server this CLI
56
+ starts on a fixed port (`127.0.0.1:51820`).
57
+ 2. You approve in the browser (or, if already signed in and previously
58
+ consented, this happens instantly with no visible prompt).
59
+ 3. Supabase redirects back to the local server with an authorization
60
+ code; the CLI exchanges it for a real session (access + refresh
61
+ token) via Supabase's PKCE flow.
62
+ 4. The session is written to `~/.88eggs/credentials.json` (mode `600`)
63
+ — never printed, never handled by anything other than this file.
64
+
65
+ Every other command reads that file, refreshing the access token via
66
+ the stored refresh token when it's near expiry, and calls
67
+ `88eggs-backend` with it as a normal `Authorization: Bearer` header —
68
+ exactly what the frontend already sends, just from a CLI instead of a
69
+ browser tab. `requireUser()` on the backend doesn't know or care which
70
+ one it came from.
71
+
72
+ **Note on the redirect URL**: this worked against production without
73
+ adding `http://127.0.0.1:51820/callback` to Supabase's redirect-URL
74
+ allowlist first — empirically, Supabase appears to treat loopback
75
+ (`127.0.0.1`/`localhost`) redirects as implicitly trusted for this kind
76
+ of installed-app flow (matching the general pattern in
77
+ [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) for native/CLI OAuth
78
+ clients), rather than requiring an exact allowlist entry the way a
79
+ hosted redirect URL would. Flagging this as observed behavior, not
80
+ something confirmed against Supabase's own docs — if a fresh project
81
+ ever rejects the redirect, add `http://127.0.0.1:51820/callback` to
82
+ **Authentication → URL Configuration → Redirect URLs** in the Supabase
83
+ dashboard.
84
+
85
+ ## Local development
86
+
87
+ Point the CLI at a local Supabase + local `88eggs-backend` instead of
88
+ production. Env vars are prefixed `EGGS_`, not `88EGGS_` — shell env var
89
+ names can't start with a digit:
90
+
91
+ ```bash
92
+ EGGS_SUPABASE_URL=http://127.0.0.1:54321 \
93
+ EGGS_SUPABASE_PUBLISHABLE_KEY=<local anon/publishable key> \
94
+ EGGS_API_URL=http://localhost:3000 \
95
+ 88eggs login
96
+ ```
97
+
98
+ Local Supabase has Google sign-in **disabled by default**
99
+ (`supabase/config.toml`'s `[auth.external.google]` block) — enable it
100
+ with real (or test) OAuth credentials first, same as any other local
101
+ Google sign-in testing against `88eggs-backend`.
102
+
103
+ ## Testing
104
+
105
+ ```bash
106
+ pnpm test # unit tests (credentials storage, token refresh logic)
107
+ pnpm typecheck
108
+ pnpm lint
109
+ ```
110
+
111
+ The OAuth round-trip itself isn't unit-tested (there's no meaningful way
112
+ to fake a real Google consent) — verified manually via `88eggs login`
113
+ against production, confirming the credentials file is written
114
+ correctly and `88eggs projects list` successfully calls the real API
115
+ afterward.
@@ -0,0 +1,84 @@
1
+ import { createServer } from "node:http";
2
+ import open from "open";
3
+ import { createAuthClient } from "../lib/supabase-client.js";
4
+ import { saveCredentials } from "../lib/credentials.js";
5
+ import { LOGIN_CALLBACK_PORT, LOGIN_CALLBACK_URL } from "../lib/constants.js";
6
+ const SUCCESS_HTML = `<!doctype html><html><body style="font-family: system-ui; text-align: center; padding: 4rem;">
7
+ <h1>Signed in to 88eggs</h1><p>You can close this tab and return to your terminal.</p>
8
+ </body></html>`;
9
+ const ERROR_HTML = (message) => `<!doctype html><html><body style="font-family: system-ui; text-align: center; padding: 4rem;">
10
+ <h1>Sign-in failed</h1><p>${message}</p><p>You can close this tab and return to your terminal.</p>
11
+ </body></html>`;
12
+ export async function login() {
13
+ const supabase = createAuthClient();
14
+ const { data, error } = await supabase.auth.signInWithOAuth({
15
+ provider: "google",
16
+ options: {
17
+ redirectTo: LOGIN_CALLBACK_URL,
18
+ skipBrowserRedirect: true,
19
+ },
20
+ });
21
+ if (error || !data.url) {
22
+ console.error("Failed to start sign-in:", error?.message ?? "no URL returned");
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ console.log("Opening your browser to sign in with Google...");
27
+ console.log(`If it doesn't open automatically, visit:\n ${data.url}\n`);
28
+ const result = await waitForCallback(supabase, data.url);
29
+ if (!result.ok) {
30
+ console.error(`Sign-in failed: ${result.message}`);
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ console.log(`Signed in as ${result.email}.`);
35
+ }
36
+ function waitForCallback(supabase, authUrl) {
37
+ return new Promise((resolve) => {
38
+ const server = createServer((request, response) => {
39
+ const url = new URL(request.url ?? "/", LOGIN_CALLBACK_URL);
40
+ if (url.pathname !== "/callback") {
41
+ response.writeHead(404).end();
42
+ return;
43
+ }
44
+ const code = url.searchParams.get("code");
45
+ const oauthError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
46
+ void (async () => {
47
+ if (oauthError) {
48
+ response.writeHead(200, { "Content-Type": "text/html" }).end(ERROR_HTML(oauthError));
49
+ server.close();
50
+ resolve({ ok: false, message: oauthError });
51
+ return;
52
+ }
53
+ if (!code) {
54
+ response.writeHead(400, { "Content-Type": "text/html" }).end(ERROR_HTML("No authorization code received."));
55
+ server.close();
56
+ resolve({ ok: false, message: "No authorization code received." });
57
+ return;
58
+ }
59
+ const { data, error } = await supabase.auth.exchangeCodeForSession(code);
60
+ if (error || !data.session) {
61
+ response.writeHead(200, { "Content-Type": "text/html" }).end(ERROR_HTML(error?.message ?? "Could not complete sign-in."));
62
+ server.close();
63
+ resolve({ ok: false, message: error?.message ?? "Could not complete sign-in." });
64
+ return;
65
+ }
66
+ await saveCredentials({
67
+ access_token: data.session.access_token,
68
+ refresh_token: data.session.refresh_token,
69
+ expires_at: data.session.expires_at ?? Math.floor(Date.now() / 1000) + data.session.expires_in,
70
+ email: data.session.user.email ?? "",
71
+ });
72
+ response.writeHead(200, { "Content-Type": "text/html" }).end(SUCCESS_HTML);
73
+ server.close();
74
+ resolve({ ok: true, email: data.session.user.email ?? "unknown" });
75
+ })();
76
+ });
77
+ server.listen(LOGIN_CALLBACK_PORT, () => {
78
+ void open(authUrl);
79
+ });
80
+ server.on("error", (err) => {
81
+ resolve({ ok: false, message: `Could not start local callback server: ${err.message}` });
82
+ });
83
+ });
84
+ }
@@ -0,0 +1,10 @@
1
+ import { clearCredentials, loadCredentials } from "../lib/credentials.js";
2
+ export async function logout() {
3
+ const credentials = await loadCredentials();
4
+ if (!credentials) {
5
+ console.log("Not signed in.");
6
+ return;
7
+ }
8
+ await clearCredentials();
9
+ console.log(`Signed out (was ${credentials.email}).`);
10
+ }
@@ -0,0 +1,32 @@
1
+ import { apiFetch, isErrorBody } from "../lib/api.js";
2
+ export async function listProjects(options) {
3
+ const query = options.scope && options.scope !== "all" ? `?scope=${options.scope}` : "";
4
+ let response;
5
+ try {
6
+ response = await apiFetch(`/projects${query}`);
7
+ }
8
+ catch (error) {
9
+ console.error(error instanceof Error ? error.message : "Request failed.");
10
+ process.exitCode = 1;
11
+ return;
12
+ }
13
+ const body = await response.json().catch(() => null);
14
+ if (!response.ok) {
15
+ const message = isErrorBody(body)
16
+ ? body.error
17
+ : `Request failed with status ${response.status}`;
18
+ console.error(`Error: ${message}`);
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ const { projects } = body;
23
+ if (projects.length === 0) {
24
+ console.log("No projects found.");
25
+ return;
26
+ }
27
+ for (const project of projects) {
28
+ const owner = project.owner?.full_name ?? "Unknown";
29
+ const sharing = project.team ? `shared with ${project.team.name}` : `owner: ${owner}`;
30
+ console.log(`${project.name} -- ${sharing} -- updated ${project.updated_at}`);
31
+ }
32
+ }
@@ -0,0 +1,16 @@
1
+ import { loadCredentials } from "../lib/credentials.js";
2
+ // Deliberately reads the local credentials file only -- no network call,
3
+ // same spirit as `vercel whoami` -- a fast, side-effect-free check a
4
+ // skill can run before attempting anything else. It doesn't verify the
5
+ // token is still valid server-side (a revoked/expired session still
6
+ // reports as "signed in" here); `88eggs projects list` etc. are what
7
+ // surface a real auth failure.
8
+ export async function whoami() {
9
+ const credentials = await loadCredentials();
10
+ if (!credentials) {
11
+ console.log("Not signed in. Run `88eggs login`.");
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ console.log(credentials.email);
16
+ }
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { login } from "./commands/login.js";
4
+ import { logout } from "./commands/logout.js";
5
+ import { whoami } from "./commands/whoami.js";
6
+ import { listProjects } from "./commands/projects.js";
7
+ const program = new Command();
8
+ program.name("88eggs").description("CLI for 88eggs").version("0.1.0");
9
+ program
10
+ .command("login")
11
+ .description("Sign in with Google via your browser")
12
+ .action(login);
13
+ program
14
+ .command("logout")
15
+ .description("Sign out and remove stored credentials")
16
+ .action(logout);
17
+ program
18
+ .command("whoami")
19
+ .description("Show the currently signed-in account")
20
+ .action(whoami);
21
+ const projects = program.command("projects").description("Manage 88eggs projects");
22
+ projects
23
+ .command("list")
24
+ .description("List your projects")
25
+ .option("--scope <scope>", "mine, shared, or all", "all")
26
+ .action((options) => listProjects(options));
27
+ program.parseAsync(process.argv);
@@ -0,0 +1,43 @@
1
+ import { createAuthClient } from "./supabase-client.js";
2
+ import { loadCredentials, saveCredentials } from "./credentials.js";
3
+ import { BACKEND_API_URL } from "./constants.js";
4
+ // Refreshes silently when the stored access token is at or near expiry
5
+ // (within 60s) -- every command that hits the API goes through this
6
+ // rather than each one separately checking expiry.
7
+ async function getAccessToken() {
8
+ const credentials = await loadCredentials();
9
+ if (!credentials) {
10
+ throw new Error("Not signed in. Run `88eggs login` first.");
11
+ }
12
+ const now = Math.floor(Date.now() / 1000);
13
+ if (credentials.expires_at - now > 60) {
14
+ return credentials.access_token;
15
+ }
16
+ const supabase = createAuthClient();
17
+ const { data, error } = await supabase.auth.refreshSession({
18
+ refresh_token: credentials.refresh_token,
19
+ });
20
+ if (error || !data.session) {
21
+ throw new Error(`Session expired and refresh failed (${error?.message ?? "unknown error"}). Run \`88eggs login\` again.`);
22
+ }
23
+ await saveCredentials({
24
+ access_token: data.session.access_token,
25
+ refresh_token: data.session.refresh_token,
26
+ expires_at: data.session.expires_at ?? now + data.session.expires_in,
27
+ email: data.session.user.email ?? credentials.email,
28
+ });
29
+ return data.session.access_token;
30
+ }
31
+ export async function apiFetch(path, init) {
32
+ const token = await getAccessToken();
33
+ const headers = new Headers(init?.headers);
34
+ headers.set("Authorization", `Bearer ${token}`);
35
+ headers.set("Content-Type", "application/json");
36
+ return fetch(`${BACKEND_API_URL}${path}`, { ...init, headers });
37
+ }
38
+ export function isErrorBody(body) {
39
+ return (typeof body === "object" &&
40
+ body !== null &&
41
+ "error" in body &&
42
+ typeof body.error === "string");
43
+ }
@@ -0,0 +1,14 @@
1
+ // Public, client-safe values -- same ones 88eggs-frontend embeds in its
2
+ // own bundle (NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY).
3
+ // Overridable via env vars for local development against a local
4
+ // Supabase/backend instance. Prefixed EGGS_ not 88EGGS_ -- shell env var
5
+ // names can't start with a digit.
6
+ export const SUPABASE_URL = process.env.EGGS_SUPABASE_URL ?? "https://kuptszxhqksekiltppgu.supabase.co";
7
+ export const SUPABASE_PUBLISHABLE_KEY = process.env.EGGS_SUPABASE_PUBLISHABLE_KEY ??
8
+ "sb_publishable_91cZ6e6YU1MrEoZbBbXaXA_Rk0TuAHT";
9
+ export const BACKEND_API_URL = process.env.EGGS_API_URL ?? "https://88eggs-backend.vercel.app";
10
+ // Fixed port for the login callback server -- fixed (not random) so it
11
+ // can be added once to Supabase's redirect-URL allowlist as an exact
12
+ // match, no wildcard needed.
13
+ export const LOGIN_CALLBACK_PORT = 51820;
14
+ export const LOGIN_CALLBACK_URL = `http://127.0.0.1:${LOGIN_CALLBACK_PORT}/callback`;
@@ -0,0 +1,24 @@
1
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ // Overridable so tests don't touch a real developer's ~/.88eggs.
5
+ const CONFIG_DIR = process.env.EGGS_CONFIG_DIR ?? join(homedir(), ".88eggs");
6
+ const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
7
+ export async function saveCredentials(credentials) {
8
+ await mkdir(CONFIG_DIR, { recursive: true });
9
+ await writeFile(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2), {
10
+ mode: 0o600,
11
+ });
12
+ }
13
+ export async function loadCredentials() {
14
+ try {
15
+ const raw = await readFile(CREDENTIALS_PATH, "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function clearCredentials() {
23
+ await rm(CREDENTIALS_PATH, { force: true });
24
+ }
@@ -0,0 +1,32 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { SUPABASE_PUBLISHABLE_KEY, SUPABASE_URL } from "./constants.js";
3
+ // In-memory storage adapter -- Node has no localStorage, and each CLI
4
+ // invocation is a fresh process anyway, so there's nothing worth
5
+ // persisting via supabase-js's own storage. It only needs *some* backing
6
+ // store to hold the PKCE code_verifier between signInWithOAuth() and
7
+ // exchangeCodeForSession() within a single `88eggs login` run. Session
8
+ // persistence across invocations is handled entirely by our own
9
+ // credentials.json (see lib/credentials.ts), not by this client.
10
+ function createMemoryStorage() {
11
+ const store = new Map();
12
+ return {
13
+ getItem: (key) => store.get(key) ?? null,
14
+ setItem: (key, value) => {
15
+ store.set(key, value);
16
+ },
17
+ removeItem: (key) => {
18
+ store.delete(key);
19
+ },
20
+ };
21
+ }
22
+ export function createAuthClient() {
23
+ return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
24
+ auth: {
25
+ flowType: "pkce",
26
+ persistSession: false,
27
+ autoRefreshToken: false,
28
+ detectSessionInUrl: false,
29
+ storage: createMemoryStorage(),
30
+ },
31
+ });
32
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "88eggs-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for 88eggs -- sign in and drive 88eggs-backend from the command line.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ptk-studio/88eggs-cli.git"
9
+ },
10
+ "homepage": "https://github.com/ptk-studio/88eggs-cli#readme",
11
+ "bugs": "https://github.com/ptk-studio/88eggs-cli/issues",
12
+ "type": "module",
13
+ "bin": {
14
+ "88eggs": "dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsx src/index.ts",
23
+ "lint": "eslint .",
24
+ "test": "vitest run",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "dependencies": {
28
+ "@supabase/supabase-js": "^2.58.0",
29
+ "commander": "^12.1.0",
30
+ "open": "^10.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@eslint/js": "^10.0.1",
34
+ "@types/node": "^22.10.2",
35
+ "eslint": "^9.17.0",
36
+ "tsx": "^4.19.2",
37
+ "typescript": "^5.7.2",
38
+ "typescript-eslint": "^8.62.1",
39
+ "vitest": "^2.1.8"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }