northbase 0.1.2 → 0.1.5

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/README.md CHANGED
@@ -103,6 +103,32 @@ On success, prints a single confirmation line to stdout:
103
103
  PUT ok test/cli.md bytes=12 updated_at=2024-01-15T10:30:00.000Z
104
104
  ```
105
105
 
106
+ ### List remote files
107
+
108
+ Prints every file path stored remotely, one per line. Useful for discovery or scripting.
109
+
110
+ ```bash
111
+ northbase list # all files
112
+ northbase list memory/ # only paths starting with "memory/"
113
+ ```
114
+
115
+ ### Pull (bulk sync)
116
+
117
+ Downloads all remote files whose `updated_at` differs from the local cache. Useful for "discover and mirror all your notes so an agent can read them locally."
118
+
119
+ ```bash
120
+ northbase pull # sync everything
121
+ northbase pull memory/ # sync only files under memory/ prefix
122
+ ```
123
+
124
+ Prints a summary line to stdout:
125
+
126
+ ```
127
+ PULL ok files=12 downloaded=3 skipped=9
128
+ ```
129
+
130
+ Per-file download progress goes to stderr. Pull never deletes local files.
131
+
106
132
  ## Local mirror
107
133
 
108
134
  All files are mirrored at:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "northbase",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Local-first CLI for reading and writing text files stored in Supabase",
5
5
  "type": "module",
6
6
  "bin": {
package/src/northbase.mjs CHANGED
@@ -16,6 +16,11 @@ const INDEX_PATH = path.join(NORTHBASE_DIR, "index.json");
16
16
  const SESSION_PATH = path.join(NORTHBASE_DIR, "session.json");
17
17
  const MAX_BYTES = 500_000;
18
18
 
19
+ const DEBUG = !!process.env.NORTHBASE_DEBUG;
20
+ function debug(...args) {
21
+ if (DEBUG) console.error("NORTHBASE DEBUG", ...args);
22
+ }
23
+
19
24
  // ── directory / index helpers ─────────────────────────────────────────────────
20
25
 
21
26
  function ensureDir(p) {
@@ -37,6 +42,13 @@ function saveIndex(idx) {
37
42
 
38
43
  // ── session helpers ───────────────────────────────────────────────────────────
39
44
 
45
+ function normalizeSession(s) {
46
+ let expiresAt = s.expires_at;
47
+ if (expiresAt && expiresAt > 1e12) expiresAt = Math.floor(expiresAt / 1000); // ms → s
48
+ if (!expiresAt && s.expires_in) expiresAt = Math.floor(Date.now() / 1000) + s.expires_in;
49
+ return { ...s, expires_at: expiresAt ?? 0 };
50
+ }
51
+
40
52
  function loadSession() {
41
53
  try {
42
54
  const s = JSON.parse(fs.readFileSync(SESSION_PATH, "utf8"));
@@ -49,7 +61,11 @@ function loadSession() {
49
61
 
50
62
  function saveSession(session) {
51
63
  ensureDir(NORTHBASE_DIR);
52
- fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
64
+ const normalized = normalizeSession(session);
65
+ const tmp = SESSION_PATH + ".tmp";
66
+ fs.writeFileSync(tmp, JSON.stringify(normalized, null, 2), { mode: 0o600 });
67
+ fs.renameSync(tmp, SESSION_PATH);
68
+ debug("session.json written expires_at=" + normalized.expires_at);
53
69
  }
54
70
 
55
71
  function deleteSession() {
@@ -58,34 +74,58 @@ function deleteSession() {
58
74
 
59
75
  // ── authenticated supabase client ─────────────────────────────────────────────
60
76
 
77
+ async function doRefresh(supabase, stored) {
78
+ debug("refresh starting");
79
+ console.error("NORTHBASE session refreshing");
80
+ const { data, error } = await supabase.auth.refreshSession({
81
+ refresh_token: stored.refresh_token,
82
+ });
83
+ if (error) {
84
+ const revoked = error.message?.includes("invalid_grant") || error.status === 400;
85
+ if (revoked) deleteSession();
86
+ throw new Error(revoked
87
+ ? "Session revoked. Run `northbase login`."
88
+ : `Session refresh failed (${error.message}). Run \`northbase login\`.`
89
+ );
90
+ }
91
+ const newSession = data.session;
92
+ if (!newSession?.access_token || !newSession?.refresh_token) {
93
+ throw new Error("Refresh returned incomplete session. Run `northbase login`.");
94
+ }
95
+ debug(`refresh ok refresh_token_rotated=${newSession.refresh_token !== stored.refresh_token}`);
96
+ saveSession(newSession);
97
+ const { error: setErr } = await supabase.auth.setSession({
98
+ access_token: newSession.access_token,
99
+ refresh_token: newSession.refresh_token,
100
+ });
101
+ if (setErr) throw setErr;
102
+ }
103
+
61
104
  async function getAuthenticatedClient() {
62
- const stored = loadSession(); // throws "Not logged in" if missing/corrupt
105
+ const stored = loadSession();
106
+ debug(`session loaded expires_at=${stored.expires_at}`);
63
107
 
64
108
  const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
65
109
  auth: { persistSession: false, autoRefreshToken: false },
66
110
  });
67
111
 
68
- const nowSec = Math.floor(Date.now() / 1000);
69
- const isExpired = stored.expires_at && nowSec >= stored.expires_at - 60;
112
+ const nowSec = Math.floor(Date.now() / 1000);
113
+ const expiresAt = stored.expires_at ?? 0;
114
+ const secsRemaining = expiresAt - nowSec;
115
+ const needsRefresh = expiresAt === 0 || secsRemaining <= 60;
116
+ debug(`seconds_remaining=${secsRemaining} needs_refresh=${needsRefresh}`);
70
117
 
71
- if (isExpired) {
72
- console.error("NORTHBASE 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 `northbase 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;
118
+ if (needsRefresh) {
119
+ await doRefresh(supabase, stored);
83
120
  } else {
84
121
  const { error } = await supabase.auth.setSession({
85
122
  access_token: stored.access_token,
86
123
  refresh_token: stored.refresh_token,
87
124
  });
88
- if (error) throw new Error("Session invalid. Run `northbase login` again.");
125
+ if (error) {
126
+ debug(`setSession failed (${error.message}) — falling back to refresh`);
127
+ await doRefresh(supabase, stored);
128
+ }
89
129
  }
90
130
 
91
131
  return supabase;
@@ -205,6 +245,68 @@ async function putFile(rel, content) {
205
245
  return { bytes, updated_at };
206
246
  }
207
247
 
248
+ // ── concurrency helper ────────────────────────────────────────────────────────
249
+
250
+ async function concurrentMap(items, limit, fn) {
251
+ const results = [];
252
+ let i = 0;
253
+ async function worker() {
254
+ while (i < items.length) {
255
+ const idx = i++;
256
+ results[idx] = await fn(items[idx]);
257
+ }
258
+ }
259
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
260
+ return results;
261
+ }
262
+
263
+ // ── list / pull commands ──────────────────────────────────────────────────────
264
+
265
+ async function cmdList(prefix) {
266
+ const supabase = await getAuthenticatedClient();
267
+ let query = supabase.from("files").select("path").order("path", { ascending: true });
268
+ if (prefix) query = query.like("path", `${prefix}%`);
269
+ const { data, error } = await query;
270
+ if (error) throw error;
271
+ for (const row of data) process.stdout.write(row.path + "\n");
272
+ }
273
+
274
+ async function cmdPull(prefix) {
275
+ ensureDir(ROOT);
276
+ const idx = loadIndex();
277
+ const supabase = await getAuthenticatedClient();
278
+
279
+ let query = supabase.from("files").select("path, updated_at").order("path", { ascending: true });
280
+ if (prefix) query = query.like("path", `${prefix}%`);
281
+ const { data: remote, error } = await query;
282
+ if (error) throw error;
283
+
284
+ let downloaded = 0, skipped = 0;
285
+
286
+ await concurrentMap(remote, 5, async (row) => {
287
+ const rel = row.path;
288
+ const cached = idx.files?.[rel];
289
+ if (cached?.updated_at === row.updated_at) {
290
+ skipped++;
291
+ return;
292
+ }
293
+ console.error("NORTHBASE PULL download", rel);
294
+ const { data: rows, error: err } = await supabase
295
+ .from("files").select("content, updated_at").eq("path", rel).limit(1);
296
+ if (err) throw err;
297
+ const content = rows?.[0]?.content ?? "";
298
+ const updated_at = rows?.[0]?.updated_at ?? row.updated_at;
299
+ const bytes = Buffer.byteLength(content, "utf8");
300
+ if (bytes > MAX_BYTES) throw new Error(`File too large (${bytes} bytes): ${rel}`);
301
+ writeLocal(rel, content);
302
+ idx.files[rel] = { updated_at, bytes };
303
+ downloaded++;
304
+ });
305
+
306
+ saveIndex(idx);
307
+ console.log(`PULL ok files=${remote.length} downloaded=${downloaded} skipped=${skipped}`);
308
+ }
309
+
208
310
  // ── interactive prompts ───────────────────────────────────────────────────────
209
311
 
210
312
  async function promptLine(question) {
@@ -294,14 +396,33 @@ async function cmdWhoami() {
294
396
  console.log(`Logged in as ${email} (${id})`);
295
397
  }
296
398
 
399
+ async function cmdSession() {
400
+ let stored;
401
+ try { stored = loadSession(); } catch {
402
+ console.log("Not logged in.");
403
+ return;
404
+ }
405
+ const nowSec = Math.floor(Date.now() / 1000);
406
+ const expiresAt = stored.expires_at ?? 0;
407
+ const secsRemaining = expiresAt - nowSec;
408
+ console.log(`now=${nowSec}`);
409
+ console.log(`expires_at=${expiresAt}`);
410
+ console.log(`seconds_remaining=${secsRemaining}`);
411
+ console.log(`will_refresh_soon=${secsRemaining <= 60}`);
412
+ console.log(`email=${stored.user?.email ?? "(unknown)"}`);
413
+ }
414
+
297
415
  // ── main ──────────────────────────────────────────────────────────────────────
298
416
 
299
417
  async function main() {
300
418
  const [cmd, ...args] = process.argv.slice(2);
301
419
 
302
- if (cmd === "login") { await cmdLogin(); return; }
303
- if (cmd === "logout") { await cmdLogout(); return; }
304
- if (cmd === "whoami") { await cmdWhoami(); return; }
420
+ if (cmd === "login") { await cmdLogin(); return; }
421
+ if (cmd === "logout") { await cmdLogout(); return; }
422
+ if (cmd === "whoami") { await cmdWhoami(); return; }
423
+ if (cmd === "session") { await cmdSession(); return; }
424
+ if (cmd === "list") { await cmdList(args[0]); return; }
425
+ if (cmd === "pull") { await cmdPull(args[0]); return; }
305
426
 
306
427
  if (cmd === "get") {
307
428
  const rel = args[0];
@@ -326,6 +447,9 @@ async function main() {
326
447
  console.log(" northbase login");
327
448
  console.log(" northbase logout");
328
449
  console.log(" northbase whoami");
450
+ console.log(" northbase session");
451
+ console.log(" northbase list [prefix]");
452
+ console.log(" northbase pull [prefix]");
329
453
  console.log(" northbase get <path>");
330
454
  console.log(" northbase put <path>");
331
455
  process.exit(1);