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 +26 -0
- package/package.json +1 -1
- package/src/northbase.mjs +144 -20
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
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
|
-
|
|
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();
|
|
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
|
|
69
|
-
const
|
|
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 (
|
|
72
|
-
|
|
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)
|
|
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")
|
|
303
|
-
if (cmd === "logout")
|
|
304
|
-
if (cmd === "whoami")
|
|
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);
|