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 +21 -0
- package/README.md +115 -0
- package/dist/commands/login.js +84 -0
- package/dist/commands/logout.js +10 -0
- package/dist/commands/projects.js +32 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/index.js +27 -0
- package/dist/lib/api.js +43 -0
- package/dist/lib/constants.js +14 -0
- package/dist/lib/credentials.js +24 -0
- package/dist/lib/supabase-client.js +32 -0
- package/package.json +44 -0
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);
|
package/dist/lib/api.js
ADDED
|
@@ -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
|
+
}
|