northbase 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +142 -0
  2. package/package.json +22 -0
  3. package/src/mybot.mjs +337 -0
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # mybot-notes-cli
2
+
3
+ A local-first CLI for reading and writing text files stored in a Supabase `public.files` table. Uses email/password auth with RLS so each user only sees their own files. Session is stored locally — no env file required.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js >= 18
8
+ - A Supabase project with the following table and RLS enabled:
9
+
10
+ ```sql
11
+ create table public.files (
12
+ path text primary key,
13
+ content text not null default '',
14
+ owner_id uuid not null references auth.users(id),
15
+ created_at timestamptz not null default now(),
16
+ updated_at timestamptz not null default now()
17
+ );
18
+
19
+ -- RLS
20
+ alter table public.files enable row level security;
21
+
22
+ create policy "owner access" on public.files
23
+ for all using (owner_id = auth.uid());
24
+
25
+ -- Auto-update updated_at on every write
26
+ create or replace function public.set_updated_at()
27
+ returns trigger language plpgsql as $$
28
+ begin
29
+ new.updated_at := now();
30
+ return new;
31
+ end;
32
+ $$;
33
+
34
+ create trigger files_updated_at
35
+ before update on public.files
36
+ for each row execute procedure public.set_updated_at();
37
+ ```
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ npm install
43
+ npm link # makes `mybot` available globally on your PATH
44
+ ```
45
+
46
+ No environment file needed. The Supabase project URL and anon key are public constants baked into the CLI.
47
+
48
+ ## Auth
49
+
50
+ ### Log in
51
+
52
+ Prompts for your email and password interactively (password is not echoed):
53
+
54
+ ```bash
55
+ mybot login
56
+ # Email: you@example.com
57
+ # Password:
58
+ # Logged in.
59
+ ```
60
+
61
+ Your session (access + refresh tokens) is stored at `~/.mybot/session.json` with mode `600`. Tokens are refreshed automatically when they expire — you should only need to log in once.
62
+
63
+ ### Log out
64
+
65
+ ```bash
66
+ mybot logout
67
+ # Logged out.
68
+ ```
69
+
70
+ Deletes `~/.mybot/session.json` and signs out server-side.
71
+
72
+ ### Check who you are
73
+
74
+ ```bash
75
+ mybot whoami
76
+ # Logged in as you@example.com (uuid...)
77
+ ```
78
+
79
+ ## Usage
80
+
81
+ ### Get a file
82
+
83
+ Fetches file content and prints it to stdout. Uses a local cache at `~/.mybot/files/` and only downloads from Supabase when the remote `updated_at` timestamp differs from the cached value.
84
+
85
+ ```bash
86
+ mybot get ideas.md
87
+ mybot get notes/todo.txt
88
+ ```
89
+
90
+ ### Put a file
91
+
92
+ Reads content from stdin, upserts it to Supabase, then updates the local cache.
93
+
94
+ ```bash
95
+ printf "hello world\n" | mybot put test/cli.md
96
+ cat my-local-file.md | mybot put ideas.md
97
+ echo "updated content" | mybot put notes/todo.txt
98
+ ```
99
+
100
+ On success, prints a single confirmation line to stdout:
101
+
102
+ ```
103
+ PUT ok test/cli.md bytes=12 updated_at=2024-01-15T10:30:00.000Z
104
+ ```
105
+
106
+ ## Local mirror
107
+
108
+ All files are mirrored at:
109
+
110
+ ```
111
+ ~/.mybot/files/<path>
112
+ ```
113
+
114
+ Metadata (timestamps and byte counts) is stored at:
115
+
116
+ ```
117
+ ~/.mybot/index.json
118
+ ```
119
+
120
+ Session tokens are stored at:
121
+
122
+ ```
123
+ ~/.mybot/session.json (mode 600 — readable only by you)
124
+ ```
125
+
126
+ The CLI compares `updated_at` timestamps before downloading — if your local copy is current, no content fetch is made.
127
+
128
+ ## Limits
129
+
130
+ - Maximum file size: **500 KB** per file (enforced on both get and put)
131
+ - Paths must not contain `.`, `..`, empty segments, or leading slashes
132
+
133
+ ## Debug output
134
+
135
+ Debug logs go to stderr so they never pollute stdout pipelines:
136
+
137
+ ```
138
+ MYBOT GET local-hit ideas.md # served from local cache
139
+ MYBOT GET remote-refresh ideas.md # downloaded from Supabase
140
+ MYBOT PUT test/cli.md bytes=12 updated_at=2024-01-15T10:30:00.000Z
141
+ MYBOT session refreshing # printed when access token is silently renewed
142
+ ```
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "northbase",
3
+ "version": "0.1.1",
4
+ "description": "Local-first CLI for reading and writing text files stored in Supabase",
5
+ "type": "module",
6
+ "bin": {
7
+ "northbase": "./src/mybot.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "dependencies": {
13
+ "@supabase/supabase-js": "^2.45.4"
14
+ },
15
+ "keywords": [
16
+ "cli",
17
+ "supabase",
18
+ "notes",
19
+ "files"
20
+ ],
21
+ "license": "MIT"
22
+ }
package/src/mybot.mjs ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { createClient } from "@supabase/supabase-js";
7
+
8
+ // ── constants ─────────────────────────────────────────────────────────────────
9
+
10
+ const SUPABASE_URL = "https://ivxgpjracfctkkdhlwgm.supabase.co";
11
+ const SUPABASE_KEY = "sb_publishable_LZkkAwsx9q5KgIAeoZAO_A_U88rfFHL";
12
+
13
+ const MYBOT_DIR = path.join(os.homedir(), ".mybot");
14
+ const ROOT = path.join(MYBOT_DIR, "files");
15
+ const INDEX_PATH = path.join(MYBOT_DIR, "index.json");
16
+ const SESSION_PATH = path.join(MYBOT_DIR, "session.json");
17
+ const MAX_BYTES = 500_000;
18
+
19
+ // ── directory / index helpers ─────────────────────────────────────────────────
20
+
21
+ function ensureDir(p) {
22
+ fs.mkdirSync(p, { recursive: true });
23
+ }
24
+
25
+ function loadIndex() {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(INDEX_PATH, "utf8"));
28
+ } catch {
29
+ return { files: {} };
30
+ }
31
+ }
32
+
33
+ function saveIndex(idx) {
34
+ ensureDir(path.dirname(INDEX_PATH));
35
+ fs.writeFileSync(INDEX_PATH, JSON.stringify(idx, null, 2));
36
+ }
37
+
38
+ // ── session helpers ───────────────────────────────────────────────────────────
39
+
40
+ function loadSession() {
41
+ try {
42
+ const s = JSON.parse(fs.readFileSync(SESSION_PATH, "utf8"));
43
+ if (!s?.access_token || !s?.refresh_token) throw new Error("incomplete");
44
+ return s;
45
+ } catch {
46
+ throw new Error("Not logged in. Run `mybot login`.");
47
+ }
48
+ }
49
+
50
+ function saveSession(session) {
51
+ ensureDir(MYBOT_DIR);
52
+ fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
53
+ }
54
+
55
+ function deleteSession() {
56
+ try { fs.unlinkSync(SESSION_PATH); } catch { /* already gone */ }
57
+ }
58
+
59
+ // ── authenticated supabase client ─────────────────────────────────────────────
60
+
61
+ async function getAuthenticatedClient() {
62
+ const stored = loadSession(); // throws "Not logged in" if missing/corrupt
63
+
64
+ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
65
+ auth: { persistSession: false, autoRefreshToken: false },
66
+ });
67
+
68
+ const nowSec = Math.floor(Date.now() / 1000);
69
+ const isExpired = stored.expires_at && nowSec >= stored.expires_at - 60;
70
+
71
+ if (isExpired) {
72
+ console.error("MYBOT session refreshing");
73
+ const { data, error } = await supabase.auth.refreshSession({
74
+ refresh_token: stored.refresh_token,
75
+ });
76
+ if (error) throw new Error("Session expired. Run `mybot login` again.");
77
+ saveSession(data.session);
78
+ const { error: setErr } = await supabase.auth.setSession({
79
+ access_token: data.session.access_token,
80
+ refresh_token: data.session.refresh_token,
81
+ });
82
+ if (setErr) throw setErr;
83
+ } else {
84
+ const { error } = await supabase.auth.setSession({
85
+ access_token: stored.access_token,
86
+ refresh_token: stored.refresh_token,
87
+ });
88
+ if (error) throw new Error("Session invalid. Run `mybot login` again.");
89
+ }
90
+
91
+ return supabase;
92
+ }
93
+
94
+ // ── path helpers ──────────────────────────────────────────────────────────────
95
+
96
+ function safeRel(pth) {
97
+ const cleaned = (pth ?? "").replaceAll("\\", "/").replace(/^\/+/, "");
98
+ const parts = cleaned.split("/");
99
+ if (!cleaned || parts.some((s) => s === "." || s === ".." || s.trim() === "")) {
100
+ throw new Error(`Unsafe path: ${pth}`);
101
+ }
102
+ return cleaned;
103
+ }
104
+
105
+ function localFullPath(rel) {
106
+ return path.join(ROOT, safeRel(rel));
107
+ }
108
+
109
+ function readLocal(rel) {
110
+ const full = localFullPath(rel);
111
+ if (!fs.existsSync(full)) return null;
112
+ return fs.readFileSync(full, "utf8");
113
+ }
114
+
115
+ function writeLocal(rel, content) {
116
+ const full = localFullPath(rel);
117
+ ensureDir(path.dirname(full));
118
+ fs.writeFileSync(full, content, "utf8");
119
+ }
120
+
121
+ // ── supabase queries ──────────────────────────────────────────────────────────
122
+
123
+ async function fetchRemoteUpdatedAt(supabase, rel) {
124
+ const relSafe = safeRel(rel);
125
+ const { data, error } = await supabase
126
+ .from("files")
127
+ .select("updated_at")
128
+ .eq("path", relSafe)
129
+ .limit(1);
130
+ if (error) throw error;
131
+ return data?.[0]?.updated_at ?? null;
132
+ }
133
+
134
+ async function fetchRemoteContent(supabase, rel) {
135
+ const relSafe = safeRel(rel);
136
+ const { data, error } = await supabase
137
+ .from("files")
138
+ .select("content, updated_at")
139
+ .eq("path", relSafe)
140
+ .limit(1);
141
+ if (error) throw error;
142
+ return { content: data?.[0]?.content ?? "", updated_at: data?.[0]?.updated_at ?? null };
143
+ }
144
+
145
+ // ── core commands ─────────────────────────────────────────────────────────────
146
+
147
+ async function getFile(rel) {
148
+ const relSafe = safeRel(rel);
149
+ const supabase = await getAuthenticatedClient();
150
+ ensureDir(ROOT);
151
+ const idx = loadIndex();
152
+ const local = readLocal(relSafe);
153
+ const cached = idx.files?.[relSafe];
154
+
155
+ if (local === null) {
156
+ console.error("MYBOT GET remote-refresh", relSafe);
157
+ const { content, updated_at } = await fetchRemoteContent(supabase, relSafe);
158
+ const bytes = Buffer.byteLength(content, "utf8");
159
+ if (bytes > MAX_BYTES) throw new Error(`File too large (${bytes} bytes)`);
160
+ writeLocal(relSafe, content);
161
+ idx.files[relSafe] = { updated_at, bytes };
162
+ saveIndex(idx);
163
+ return content;
164
+ }
165
+
166
+ const remoteUpdatedAt = await fetchRemoteUpdatedAt(supabase, relSafe);
167
+ if (!remoteUpdatedAt) return local;
168
+
169
+ if (cached?.updated_at === remoteUpdatedAt) {
170
+ console.error("MYBOT GET local-hit", relSafe);
171
+ return local;
172
+ }
173
+
174
+ console.error("MYBOT GET remote-refresh", relSafe);
175
+ const { content, updated_at } = await fetchRemoteContent(supabase, relSafe);
176
+ const bytes = Buffer.byteLength(content, "utf8");
177
+ if (bytes > MAX_BYTES) throw new Error(`File too large (${bytes} bytes)`);
178
+ writeLocal(relSafe, content);
179
+ idx.files[relSafe] = { updated_at, bytes };
180
+ saveIndex(idx);
181
+ return content;
182
+ }
183
+
184
+ async function putFile(rel, content) {
185
+ const relSafe = safeRel(rel);
186
+ const bytes = Buffer.byteLength(content, "utf8");
187
+ if (bytes > MAX_BYTES) throw new Error(`File too large (${bytes} bytes)`);
188
+
189
+ const supabase = await getAuthenticatedClient();
190
+ ensureDir(ROOT);
191
+ const idx = loadIndex();
192
+
193
+ const { error } = await supabase
194
+ .from("files")
195
+ .upsert({ path: relSafe, content }, { onConflict: "path" });
196
+ if (error) throw error;
197
+
198
+ const updated_at = await fetchRemoteUpdatedAt(supabase, relSafe);
199
+
200
+ writeLocal(relSafe, content);
201
+ idx.files[relSafe] = { updated_at, bytes };
202
+ saveIndex(idx);
203
+
204
+ console.error("MYBOT PUT", relSafe, `bytes=${bytes}`, `updated_at=${updated_at}`);
205
+ return { bytes, updated_at };
206
+ }
207
+
208
+ // ── interactive prompts ───────────────────────────────────────────────────────
209
+
210
+ async function promptLine(question) {
211
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
212
+ try {
213
+ return await rl.question(question);
214
+ } finally {
215
+ rl.close();
216
+ }
217
+ }
218
+
219
+ async function promptPassword(question) {
220
+ if (!process.stdin.isTTY) {
221
+ throw new Error("`mybot login` requires an interactive terminal (stdin is not a TTY).");
222
+ }
223
+ return new Promise((resolve) => {
224
+ process.stdout.write(question);
225
+ let pass = "";
226
+
227
+ const onData = (buf) => {
228
+ const ch = buf.toString("utf8");
229
+ if (ch === "\r" || ch === "\n" || ch === "\u0004") {
230
+ // enter / ctrl-d
231
+ process.stdin.setRawMode(false);
232
+ process.stdin.removeListener("data", onData);
233
+ process.stdin.pause();
234
+ process.stdout.write("\n");
235
+ resolve(pass);
236
+ } else if (ch === "\u0003") {
237
+ // ctrl-c
238
+ process.stdin.setRawMode(false);
239
+ process.stdout.write("\n");
240
+ process.exit(1);
241
+ } else if (ch === "\u007f" || ch === "\b") {
242
+ // backspace
243
+ if (pass.length > 0) pass = pass.slice(0, -1);
244
+ } else {
245
+ pass += ch;
246
+ }
247
+ };
248
+
249
+ process.stdin.setRawMode(true);
250
+ process.stdin.resume();
251
+ process.stdin.on("data", onData);
252
+ });
253
+ }
254
+
255
+ // ── auth commands ─────────────────────────────────────────────────────────────
256
+
257
+ async function cmdLogin() {
258
+ const email = await promptLine("Email: ");
259
+ const password = await promptPassword("Password: ");
260
+
261
+ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
262
+ auth: { persistSession: false },
263
+ });
264
+
265
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
266
+ if (error) throw error;
267
+
268
+ saveSession(data.session);
269
+ console.log("Logged in.");
270
+ }
271
+
272
+ async function cmdLogout() {
273
+ let supabase;
274
+ try {
275
+ supabase = await getAuthenticatedClient();
276
+ } catch { /* not logged in — still clean up local session */ }
277
+
278
+ if (supabase) {
279
+ try { await supabase.auth.signOut(); } catch { /* best-effort */ }
280
+ }
281
+
282
+ deleteSession();
283
+ console.log("Logged out.");
284
+ }
285
+
286
+ async function cmdWhoami() {
287
+ let stored;
288
+ try { stored = loadSession(); } catch {
289
+ console.log("Not logged in.");
290
+ return;
291
+ }
292
+ const email = stored.user?.email ?? "(unknown)";
293
+ const id = stored.user?.id ?? "(unknown)";
294
+ console.log(`Logged in as ${email} (${id})`);
295
+ }
296
+
297
+ // ── main ──────────────────────────────────────────────────────────────────────
298
+
299
+ async function main() {
300
+ const [cmd, ...args] = process.argv.slice(2);
301
+
302
+ if (cmd === "login") { await cmdLogin(); return; }
303
+ if (cmd === "logout") { await cmdLogout(); return; }
304
+ if (cmd === "whoami") { await cmdWhoami(); return; }
305
+
306
+ if (cmd === "get") {
307
+ const rel = args[0];
308
+ if (!rel) { console.log("Usage: mybot get <path>"); process.exit(1); }
309
+ const content = await getFile(rel);
310
+ process.stdout.write(content);
311
+ return;
312
+ }
313
+
314
+ if (cmd === "put") {
315
+ const rel = args[0];
316
+ if (!rel) { console.log("Usage: mybot put <path>"); process.exit(1); }
317
+ const chunks = [];
318
+ for await (const chunk of process.stdin) chunks.push(chunk);
319
+ const content = Buffer.concat(chunks).toString("utf8");
320
+ const result = await putFile(rel, content);
321
+ console.log(`PUT ok ${safeRel(rel)} bytes=${result.bytes} updated_at=${result.updated_at}`);
322
+ return;
323
+ }
324
+
325
+ console.log("Usage:");
326
+ console.log(" mybot login");
327
+ console.log(" mybot logout");
328
+ console.log(" mybot whoami");
329
+ console.log(" mybot get <path>");
330
+ console.log(" mybot put <path>");
331
+ process.exit(1);
332
+ }
333
+
334
+ main().catch((e) => {
335
+ console.error("MYBOT:", e?.message ?? e);
336
+ process.exit(1);
337
+ });